mirror of
https://github.com/meowarex/rl-mobile.git
synced 2026-08-27 14:37:45 +10:00
Alpha
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.util.back
|
||||
|
||||
/**
|
||||
* Standalone back button for interacting with the current navigator.
|
||||
*/
|
||||
@Composable
|
||||
fun BackButton() {
|
||||
val navigator = LocalNavigator.current
|
||||
val activity = LocalActivity.current
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator?.back(activity)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.navigation_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
|
||||
@Composable
|
||||
fun ContributorCommitsItem(
|
||||
user: Contributor,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier
|
||||
.clickable { uriHandler.openUri("https://github.com/${user.username}") }
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = user.avatarUrl,
|
||||
contentDescription = user.username,
|
||||
error = {
|
||||
Surface(
|
||||
content = {},
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.shimmer(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(top = 6.dp)
|
||||
.size(45.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = user.username,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.contributors_contributions, user.commits),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = user.repositories.joinToString { it.name },
|
||||
fontStyle = FontStyle.Italic,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
.copy(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2025 zt64
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
// Adapted from compose-pipette:
|
||||
// https://github.com/zt64/compose-pipette/blob/3e9fd958a315dceb142bf30250b4614ecde4e723/sample/src/commonMain/kotlin/dev/zt64/compose/pipette/sample/SampleSlider.kt
|
||||
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.center
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun InteractiveSlider(
|
||||
value: Float,
|
||||
onValueChange: (Float) -> Unit,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
brush: Brush,
|
||||
thumbColor: Color = MaterialTheme.colorScheme.primary,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
|
||||
Slider(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
thumb = {
|
||||
val interactions = remember { mutableStateListOf<Interaction>() }
|
||||
|
||||
LaunchedEffect(interactionSource) {
|
||||
interactionSource.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> interactions.add(interaction)
|
||||
is PressInteraction.Release -> interactions.remove(interaction.press)
|
||||
is PressInteraction.Cancel -> interactions.remove(interaction.press)
|
||||
is DragInteraction.Start -> interactions.add(interaction)
|
||||
is DragInteraction.Stop -> interactions.remove(interaction.start)
|
||||
is DragInteraction.Cancel -> interactions.remove(interaction.start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
val visualSize by remember {
|
||||
derivedStateOf {
|
||||
if (interactions.isNotEmpty()) 28.dp else 24.dp
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(visualSize)
|
||||
.align(Alignment.Center)
|
||||
.background(thumbColor, CircleShape),
|
||||
)
|
||||
}
|
||||
},
|
||||
track = {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 700.dp)
|
||||
.height(12.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
drawLine(
|
||||
brush = brush,
|
||||
start = Offset(0f, size.center.y),
|
||||
end = Offset(size.width, size.center.y),
|
||||
strokeWidth = size.height,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = valueRange,
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
|
||||
@Composable
|
||||
fun Label(
|
||||
name: String,
|
||||
description: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.thenIf(description == null) { padding(bottom = 4.dp) },
|
||||
)
|
||||
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun LoadFailure(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(34.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.network_load_fail),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MainActionButton(
|
||||
text: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = colors,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(46.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun ProjectHeader(modifier: Modifier = Modifier) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.rlmobile),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_description),
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
TextButton(onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_account_github_white_24dp),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = ButtonDefaults.IconSpacing),
|
||||
)
|
||||
Text(text = stringResource(R.string.github))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.util.mirrorVertically
|
||||
|
||||
@Composable
|
||||
fun ResetToDefaultButton(
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter = fadeIn() + slideInHorizontally(),
|
||||
exit = fadeOut() + slideOutHorizontally(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_refresh),
|
||||
tint = MaterialTheme.colorScheme.secondary,
|
||||
contentDescription = stringResource(R.string.action_reset_default),
|
||||
modifier = Modifier
|
||||
.mirrorVertically()
|
||||
.size(26.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun RowScope.SegmentedButton(
|
||||
icon: Painter,
|
||||
iconColor: Color = MaterialTheme.colorScheme.primary,
|
||||
iconDescription: String? = null,
|
||||
text: String,
|
||||
textColor: Color = MaterialTheme.colorScheme.primary,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.weight(1f)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = iconDescription,
|
||||
tint = iconColor,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TextDivider(
|
||||
text: String,
|
||||
style: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
HorizontalDivider(Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.*
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
|
||||
@Composable
|
||||
fun VersionDisplay(
|
||||
version: TidalVersion,
|
||||
prefix: (@Composable AnnotatedString.Builder.() -> Unit)? = null,
|
||||
style: TextStyle = MaterialTheme.typography.labelLarge,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
prefix?.invoke(this)
|
||||
|
||||
if (version is TidalVersion.Existing) {
|
||||
append(version.name)
|
||||
append(" - ")
|
||||
}
|
||||
append(version.toDisplayName())
|
||||
},
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.meowarex.rlmobile.ui.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
|
||||
/**
|
||||
* Maintain an active screen wakelock as long as [active] is true and this component is in scope.
|
||||
*/
|
||||
@Composable
|
||||
fun Wakelock(active: Boolean = false) {
|
||||
val window = LocalActivity.currentOrThrow.window
|
||||
DisposableEffect(active) {
|
||||
if (active) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun InstallerAbortDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_exit_anyways))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.installer_abort_title))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.installer_abort_body),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textContentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun NetworkWarningDialog(
|
||||
onConfirm: (neverShow: Boolean) -> Unit,
|
||||
onDismiss: (neverShow: Boolean) -> Unit,
|
||||
) {
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
var neverShow by rememberSaveable { mutableStateOf(false) }
|
||||
val rememberedNeverShow by rememberUpdatedState(neverShow)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss(rememberedNeverShow) },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
),
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = { onConfirm(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.action_continue))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { onDismiss(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.navigation_back))
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.network_warning_title)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.network_warning_body),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { neverShow = !rememberedNeverShow },
|
||||
)
|
||||
.padding(end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = neverShow,
|
||||
onCheckedChange = { neverShow = it },
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.network_warning_disable))
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
textContentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.meowarex.rlmobile.ui.components.dialogs
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.theme.customColors
|
||||
|
||||
@Composable
|
||||
fun PlayProtectDialog(
|
||||
onDismiss: (neverShow: Boolean) -> Unit,
|
||||
) {
|
||||
val activity = LocalActivity.currentOrThrow
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
var neverShow by rememberSaveable { mutableStateOf(false) }
|
||||
val rememberedNeverShow by rememberUpdatedState(neverShow)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { onDismiss(rememberedNeverShow) },
|
||||
dismissButton = {
|
||||
FilledTonalButton(onClick = activity::launchPlayProtect) {
|
||||
Text(stringResource(R.string.play_protect_warning_open_gpp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FilledTonalButton(
|
||||
onClick = { onDismiss(rememberedNeverShow) },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
),
|
||||
) {
|
||||
Text(stringResource(R.string.play_protect_warning_ok))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_protect_warning),
|
||||
tint = MaterialTheme.customColors.warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.play_protect_warning_title)) },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.play_protect_warning_desc),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = { neverShow = !rememberedNeverShow },
|
||||
)
|
||||
.padding(end = 16.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = neverShow,
|
||||
onCheckedChange = { neverShow = it },
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.play_protect_warning_disable))
|
||||
}
|
||||
}
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(25.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun Activity.launchPlayProtect() {
|
||||
Intent("com.google.android.gms.settings.VERIFY_APPS_SETTINGS")
|
||||
.setPackage("com.google.android.gms")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
|
||||
.also(::startActivity)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.components.TextDivider
|
||||
|
||||
@Composable
|
||||
fun SettingsHeader(
|
||||
text: String,
|
||||
) {
|
||||
TextDivider(
|
||||
text = text,
|
||||
modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp)
|
||||
)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SettingsItem(
|
||||
text: @Composable () -> Unit,
|
||||
secondaryText: @Composable (() -> Unit) = { },
|
||||
icon: @Composable (() -> Unit) = { },
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit) = { },
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.heightIn(min = 64.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.weight(2f, true)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(20.dp)) {
|
||||
icon()
|
||||
}
|
||||
|
||||
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
|
||||
text()
|
||||
}
|
||||
}
|
||||
|
||||
ProvideTextStyle(
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
|
||||
)
|
||||
) {
|
||||
secondaryText()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(0.05f, true))
|
||||
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitch(
|
||||
label: String,
|
||||
secondaryLabel: String? = null,
|
||||
disabled: Boolean = false,
|
||||
icon: @Composable () -> Unit = {},
|
||||
pref: Boolean,
|
||||
onPrefChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SettingsItem(
|
||||
modifier = modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
|
||||
text = { Text(text = label, softWrap = true) },
|
||||
icon = icon,
|
||||
secondaryText = {
|
||||
secondaryLabel?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Switch(
|
||||
checked = pref,
|
||||
enabled = !disabled,
|
||||
onCheckedChange = { onPrefChange(!pref) }
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.meowarex.rlmobile.ui.components.settings
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SettingsTextField(
|
||||
label: String,
|
||||
disabled: Boolean = false,
|
||||
pref: String,
|
||||
error: Boolean = false,
|
||||
onPrefChange: (String) -> Unit,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = pref,
|
||||
onValueChange = onPrefChange,
|
||||
enabled = !disabled,
|
||||
label = { Text(label) },
|
||||
isError = error,
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.meowarex.rlmobile.ui.previews
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patching.components.TextBanner
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.theme.customColors
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ButtonVotePreview(
|
||||
@PreviewParameter(TextBannerParametersProvider::class)
|
||||
parameters: TextBannerParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
TextBanner(
|
||||
text = parameters.text(),
|
||||
icon = parameters.icon(),
|
||||
iconColor = parameters.iconColor(),
|
||||
outlineColor = parameters.outlineColor(),
|
||||
containerColor = parameters.containerColor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TextBannerParameters(
|
||||
val text: @Composable () -> String,
|
||||
val icon: @Composable () -> Painter,
|
||||
val iconColor: @Composable () -> Color,
|
||||
val outlineColor: @Composable () -> Color?,
|
||||
val containerColor: @Composable () -> Color,
|
||||
)
|
||||
|
||||
private class TextBannerParametersProvider : PreviewParameterProvider<TextBannerParameters> {
|
||||
override val values = sequenceOf(
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_minimization) },
|
||||
icon = { painterResource(R.drawable.ic_warning) },
|
||||
iconColor = { MaterialTheme.customColors.onWarningContainer },
|
||||
outlineColor = { MaterialTheme.customColors.warning },
|
||||
containerColor = { MaterialTheme.customColors.warningContainer },
|
||||
),
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_failure) },
|
||||
icon = { painterResource(R.drawable.ic_warning) },
|
||||
iconColor = { MaterialTheme.colorScheme.error },
|
||||
outlineColor = { null },
|
||||
containerColor = { MaterialTheme.colorScheme.errorContainer },
|
||||
),
|
||||
TextBannerParameters(
|
||||
text = { stringResource(R.string.installer_banner_success) },
|
||||
icon = { painterResource(R.drawable.ic_check_circle) },
|
||||
iconColor = { Color(0xFF59B463) },
|
||||
outlineColor = { MaterialTheme.colorScheme.surfaceVariant },
|
||||
containerColor = { MaterialTheme.colorScheme.surfaceContainerHigh },
|
||||
)
|
||||
)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun DeleteLogsDialogPreview() {
|
||||
ManagerTheme {
|
||||
DeleteLogsDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun InstallerAbortDialogPreview() {
|
||||
ManagerTheme {
|
||||
InstallerAbortDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun NetworkWarningDialogPreview() {
|
||||
ManagerTheme {
|
||||
NetworkWarningDialog(
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.PlayProtectDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PlayProtectDialogPreview() {
|
||||
ManagerTheme {
|
||||
PlayProtectDialog(onDismiss = {})
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.settings.components.ThemeDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.theme.Theme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ThemeDialogPreview() {
|
||||
val (theme, setTheme) = remember { mutableStateOf(Theme.System) }
|
||||
|
||||
ManagerTheme {
|
||||
ThemeDialog(
|
||||
currentTheme = theme,
|
||||
onDismiss = {},
|
||||
onConfirm = setTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.meowarex.rlmobile.ui.previews.dialogs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun UninstallPluginDialogPreview() {
|
||||
ManagerTheme {
|
||||
UninstallPluginDialog(
|
||||
pluginName = "FakeNitro",
|
||||
onConfirm = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun ComponentOptionsScreenPreview(
|
||||
@PreviewParameter(ComponentOptionsParametersProvider::class)
|
||||
parameters: ComponentOptionsParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
ComponentOptionsScreenContent(
|
||||
componentType = parameters.componentType,
|
||||
components = parameters.components,
|
||||
selected = parameters.selected,
|
||||
onSelectComponent = {},
|
||||
onDeleteComponent = {},
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ComponentOptionsParameters(
|
||||
val componentType: PatchComponent.Type,
|
||||
val components: ImmutableList<PatchComponent>,
|
||||
val selected: PatchComponent?,
|
||||
)
|
||||
|
||||
private class ComponentOptionsParametersProvider : PreviewParameterProvider<ComponentOptionsParameters> {
|
||||
private val components = persistentListOf(
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(1, 2, 3),
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(2, 3, 1),
|
||||
timestamp = Clock.System.now() - 10.minutes,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(2, 3, 1),
|
||||
timestamp = Clock.System.now() - 1.days,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(0, 0, 1),
|
||||
timestamp = Clock.System.now() - 10.hours,
|
||||
),
|
||||
PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(3, 0, 2),
|
||||
timestamp = Clock.System.now() - 7.days,
|
||||
),
|
||||
)
|
||||
|
||||
override val values = sequenceOf(
|
||||
ComponentOptionsParameters(
|
||||
componentType = PatchComponent.Type.Injector,
|
||||
components = components,
|
||||
selected = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.*
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlin.time.Clock
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PatchOptionsScreenPreview(
|
||||
@PreviewParameter(PatchOptionsParametersProvider::class)
|
||||
parameters: PatchOptionsParameters,
|
||||
) {
|
||||
ManagerTheme {
|
||||
PatchOptionsScreenContent(
|
||||
isUpdate = parameters.isUpdate,
|
||||
isDevMode = parameters.isDevMode,
|
||||
debuggable = parameters.debuggable,
|
||||
setDebuggable = {},
|
||||
appName = parameters.appName,
|
||||
appNameIsError = parameters.appNameIsError,
|
||||
setAppName = {},
|
||||
packageName = parameters.packageName,
|
||||
packageNameState = parameters.packageNameState,
|
||||
setPackageName = {},
|
||||
customInjector = parameters.customInjector,
|
||||
onSelectCustomInjector = {},
|
||||
customPatches = parameters.customPatches,
|
||||
onSelectCustomPatches = {},
|
||||
isConfigValid = parameters.isConfigValid,
|
||||
onInstall = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PatchOptionsParameters(
|
||||
val isUpdate: Boolean,
|
||||
val isDevMode: Boolean,
|
||||
val debuggable: Boolean,
|
||||
val appName: String,
|
||||
val appNameIsError: Boolean,
|
||||
val packageName: String,
|
||||
val packageNameState: PackageNameState,
|
||||
val customInjector: PatchComponent?,
|
||||
val customPatches: PatchComponent?,
|
||||
val isConfigValid: Boolean,
|
||||
)
|
||||
|
||||
private class PatchOptionsParametersProvider : PreviewParameterProvider<PatchOptionsParameters> {
|
||||
override val values = sequenceOf(
|
||||
PatchOptionsParameters(
|
||||
isUpdate = false,
|
||||
isDevMode = false,
|
||||
debuggable = false,
|
||||
appName = PatchOptions.Default.appName,
|
||||
appNameIsError = false,
|
||||
packageName = PatchOptions.Default.packageName,
|
||||
packageNameState = PackageNameState.Ok,
|
||||
customInjector = null,
|
||||
customPatches = null,
|
||||
isConfigValid = true,
|
||||
),
|
||||
PatchOptionsParameters(
|
||||
isUpdate = true,
|
||||
isDevMode = false,
|
||||
debuggable = false,
|
||||
appName = "an invalid app name.",
|
||||
appNameIsError = true,
|
||||
packageName = "a b",
|
||||
packageNameState = PackageNameState.Invalid,
|
||||
customInjector = null,
|
||||
customPatches = null,
|
||||
isConfigValid = false,
|
||||
),
|
||||
PatchOptionsParameters(
|
||||
isUpdate = false,
|
||||
isDevMode = true,
|
||||
debuggable = true,
|
||||
appName = PatchOptions.Default.appName,
|
||||
appNameIsError = false,
|
||||
packageName = PatchOptions.Default.packageName,
|
||||
packageNameState = PackageNameState.Taken,
|
||||
customInjector = PatchComponent(
|
||||
type = PatchComponent.Type.Injector,
|
||||
version = SemVer(1, 2, 3),
|
||||
timestamp = Clock.System.now(),
|
||||
),
|
||||
customPatches = null,
|
||||
isConfigValid = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.manager.InstallerSetting
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
fun PermissionsScreenPreview() {
|
||||
ManagerTheme {
|
||||
PermissionsScreenContent(
|
||||
installer = InstallerSetting.PackageInstaller,
|
||||
openInstallersDialog = {},
|
||||
storagePermsGranted = true,
|
||||
onGrantStoragePerms = {},
|
||||
unknownSourcesPermsGranted = true,
|
||||
onGrantUnknownSourcesPerms = {},
|
||||
notificationsPermsGranted = false,
|
||||
onGrantNotificationsPerms = {},
|
||||
batteryPermsGranted = false,
|
||||
onGrantBatteryPerms = {},
|
||||
canContinue = true,
|
||||
onContinue = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Failure) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.util.emptyImmutableList
|
||||
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
|
||||
import kotlinx.collections.immutable.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
// This preview has scrollable content that cannot be properly viewed from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenLoadedPreview(
|
||||
@PreviewParameter(ContributorsProvider::class)
|
||||
contributors: ImmutableList<Contributor>,
|
||||
) {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Loaded(contributors)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class ContributorsProvider : PreviewParameterProvider<ImmutableList<Contributor>> {
|
||||
@Suppress("unused")
|
||||
private val realDataRaw =
|
||||
"[{\"username\":\"meowarex\",\"avatarUrl\":\"https://avatars.githubusercontent.com/u/0?v=4\",\"commits\":1,\"repositories\":[{\"name\":\"Radiant Lyrics\",\"commits\":1}]}]"
|
||||
private val realData = Json.decodeFromString(ImmutableListSerializer(Contributor.serializer()), realDataRaw)
|
||||
|
||||
override val values = sequenceOf(
|
||||
emptyImmutableList<Contributor>(),
|
||||
persistentListOf(
|
||||
Contributor(
|
||||
username = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
avatarUrl = "UNUSED",
|
||||
commits = Int.MAX_VALUE,
|
||||
repositories = (realData[0].repositories + realData[0].repositories).toImmutableList(),
|
||||
)
|
||||
),
|
||||
realData,
|
||||
)
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.about
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreenState
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
// This preview cannot be properly viewed from an IDE
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun AboutScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
AboutScreenContent(
|
||||
state = remember { mutableStateOf(AboutScreenState.Loading) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreenFailureContent
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun HomeScreenFailedPreview() {
|
||||
ManagerTheme {
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
HomeScreenFailureContent(padding = padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.tooling.preview.*
|
||||
import com.meowarex.rlmobile.ui.screens.home.*
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlin.io.encoding.Base64
|
||||
|
||||
// This preview has animations that cannot be properly viewed from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun HomeScreenLoadedPreview(
|
||||
@PreviewParameter(HomeScreenParametersProvider::class)
|
||||
state: InstallsState.Fetched,
|
||||
) {
|
||||
ManagerTheme {
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
HomeScreenLoadedContent(
|
||||
state = state,
|
||||
padding = padding,
|
||||
onClickInstall = {},
|
||||
onUpdate = {},
|
||||
onOpenApp = {},
|
||||
onOpenAppInfo = {},
|
||||
onOpenPlugins = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class HomeScreenParametersProvider : PreviewParameterProvider<InstallsState.Fetched> {
|
||||
private val stableVersion = TidalVersion.Existing(TidalVersion.Type.STABLE, "126.21", 126021)
|
||||
private val radiantIconBytes = Base64.decode(
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAKBueIx4ZKCMgoy0qqC+8P//8Nzc8P//////////////////////////////////////////////////////////2wBDAaq0tPDS8P//////////////////////////////////////////////////////////////////////////////wAARCAC9AL0DASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAIDAf/EACIQAQEAAgEFAAIDAAAAAAAAAAABAhEhAxIxQVETMiJhcf/EABcBAQEBAQAAAAAAAAAAAAAAAAABAgP/xAAZEQEBAQEBAQAAAAAAAAAAAAAAARECEjH/2gAMAwEAAhEDEQA/AMsce7/Gkkngk1NOoxboAIAAAAAAAAAAAAAAAAAAm4ys7NXVbJyx2LKoAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHZLfRcbJug4AABJugDtxs9OAAAAAAAAAAAAAA7jN0HccN81cxk9OityBZuaAVP459OyKBMifxwxw1dqAyBrYCpuEvhFmq1TnNwZsZgIyAAAAAAAANcJqM8ZutRrkAVoAAAAAAAAABnnNVLTObjNGKACAAAAAAL6c9rcxmsY6rcALdTYrlyk8kzlZeRGPTYThdzSlbC2TyMsruiW4vvimK+nedIkqwFaGN4rZnnP5DPSQEZAAAAAJ5BtPACug5n+tdAYi8sL6cmFqOeO9P2sk1NCtwYtk5Yb5gljNWH7Odt+NMce2IkjoCtiOp6WjqeIJfiAEYAAAACeYANgllnAroA5lbJxAdEzOe+FblDQAAAAcuUntyZW3iCaoAUT1PEUjOy8CX4gBGAAAAAACWzw0mf1mBLjYZS2eFTqfVa9O9TWv7Zu27u3EZtd7r9O/L64Brvdfrm79AHZ55asVY56mhZWjlykRc7Ui3pWWdqQGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMbuOsZbLw0xy2LYoAQAAAAAAAAAAAAAAAAAARnlzqGWdnEQNSP/2Q=="
|
||||
)
|
||||
private val radiantIcon = BitmapFactory
|
||||
.decodeByteArray(radiantIconBytes, 0, radiantIconBytes.size)
|
||||
.asImageBitmap()
|
||||
.let(::BitmapPainter)
|
||||
|
||||
override val values = sequenceOf(
|
||||
InstallsState.Fetched(
|
||||
persistentListOf(
|
||||
InstallData(
|
||||
name = "Radiant Lyrics",
|
||||
packageName = "com.radiantLyrics",
|
||||
version = stableVersion,
|
||||
icon = radiantIcon,
|
||||
isUpToDate = true,
|
||||
)
|
||||
)
|
||||
),
|
||||
InstallsState.Fetched(
|
||||
persistentListOf(
|
||||
InstallData(
|
||||
name = "Tidal",
|
||||
packageName = "com.tidal",
|
||||
version = stableVersion,
|
||||
icon = radiantIcon,
|
||||
isUpToDate = false,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreenLoadingContent
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
// This preview cannot be properly viewed from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun HomeScreenLoadingPreview() {
|
||||
ManagerTheme {
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
HomeScreenLoadingContent(padding = padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreenNoneContent
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.HomeAppBar
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun HomeScreenNonePreview() {
|
||||
ManagerTheme {
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
HomeScreenNoneContent(
|
||||
padding = padding,
|
||||
onClickInstall = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.logs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogEntry
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun LogsListScreenNonePreview() {
|
||||
ManagerTheme {
|
||||
LogsScreenContent(
|
||||
logs = logs,
|
||||
onOpenLog = {},
|
||||
onDeleteLogs = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val logs: SnapshotStateList<LogEntry> = mutableStateListOf(
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = false,
|
||||
installDate = "5 min. ago, 10:18 AM",
|
||||
durationSecs = 18.555f,
|
||||
stacktracePreview = null,
|
||||
),
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = true,
|
||||
installDate = "7 min. ago, 10:17 AM",
|
||||
durationSecs = 73.095f,
|
||||
stacktracePreview = persistentListOf(
|
||||
"kotlinx.coroutines.JobCancellationException: Job was cancelled; job=SupervisorJobImpl{Cancelling}@833e76f]",
|
||||
),
|
||||
),
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = false,
|
||||
installDate = "Yesterday, 11:37 PM",
|
||||
durationSecs = 58.439f,
|
||||
stacktracePreview = null,
|
||||
),
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = true,
|
||||
installDate = "Yesterday, 11:17 PM",
|
||||
durationSecs = 24.405f,
|
||||
stacktracePreview = persistentListOf(
|
||||
"java.lang.Error: Installation was aborted or cancelled",
|
||||
),
|
||||
),
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = true,
|
||||
installDate = "Yesterday, 11:17 PM",
|
||||
durationSecs = 0.057f,
|
||||
stacktracePreview = persistentListOf(
|
||||
"java.lang.IllegalStateException: balls",
|
||||
"\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep.execute(FetchInfoStep.kt:31)",
|
||||
"\tat com.meowarex.rlmobile.patcher.steps.prepare.FetchInfoStep\$execute\\$1.invokeSuspend(Unknown Source:15)",
|
||||
),
|
||||
),
|
||||
LogEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
isError = false,
|
||||
installDate = "Yesterday, 1:11 PM",
|
||||
durationSecs = 210.539f,
|
||||
stacktracePreview = null,
|
||||
),
|
||||
)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.logs
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun LogsListScreenNonePreview() {
|
||||
ManagerTheme {
|
||||
LogsScreenContent(
|
||||
logs = remember { mutableStateListOf() },
|
||||
onOpenLog = {},
|
||||
onDeleteLogs = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.plugins
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.util.emptyImmutableList
|
||||
|
||||
// This preview has interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PluginsScreenFailedPreview() {
|
||||
val filterState = remember { mutableStateOf("") }
|
||||
|
||||
ManagerTheme {
|
||||
PluginsScreenContent(
|
||||
searchText = filterState,
|
||||
setSearchText = filterState::value::set,
|
||||
isError = true,
|
||||
plugins = emptyImmutableList(),
|
||||
onPluginUninstall = {},
|
||||
onPluginChangelog = {},
|
||||
onPluginToggle = { name, enabled -> },
|
||||
safeMode = false,
|
||||
setSafeMode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.plugins
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import java.util.UUID
|
||||
|
||||
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PluginsScreenLoadedPreview() {
|
||||
val filterState = remember { mutableStateOf("test") }
|
||||
|
||||
ManagerTheme {
|
||||
PluginsScreenContent(
|
||||
searchText = filterState,
|
||||
setSearchText = filterState::value::set,
|
||||
isError = false,
|
||||
plugins = plugins,
|
||||
onPluginUninstall = {},
|
||||
onPluginChangelog = {},
|
||||
onPluginToggle = { name, enabled -> },
|
||||
safeMode = false,
|
||||
setSafeMode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val plugins: ImmutableList<PluginItem> = persistentListOf(
|
||||
PluginItem(
|
||||
path = UUID.randomUUID().toString(),
|
||||
manifest = PluginManifest(
|
||||
name = "CloseDMs",
|
||||
authors = persistentListOf(
|
||||
PluginManifest.Author(name = "Diamond", id = 0L),
|
||||
),
|
||||
description = "Shortcut to close DMs in the DM context menu.",
|
||||
version = "1.0.0",
|
||||
updateUrl = "",
|
||||
changelog = "",
|
||||
changelogMedia = null,
|
||||
)
|
||||
),
|
||||
PluginItem(
|
||||
path = UUID.randomUUID().toString(),
|
||||
manifest = PluginManifest(
|
||||
name = "ConfigurableStickerSizes",
|
||||
authors = persistentListOf(
|
||||
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
|
||||
),
|
||||
description = "Makes sticker sizes configurable.",
|
||||
version = "1.1.5",
|
||||
updateUrl = "",
|
||||
changelog = "",
|
||||
changelogMedia = null,
|
||||
)
|
||||
),
|
||||
PluginItem(
|
||||
path = UUID.randomUUID().toString(),
|
||||
manifest = PluginManifest(
|
||||
name = "AudioPlayer",
|
||||
authors = persistentListOf(
|
||||
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
|
||||
),
|
||||
description = "Makes audio files playable.",
|
||||
version = "0.0.1",
|
||||
updateUrl = "",
|
||||
changelog = "",
|
||||
changelogMedia = null,
|
||||
)
|
||||
),
|
||||
PluginItem(
|
||||
path = UUID.randomUUID().toString(),
|
||||
manifest = PluginManifest(
|
||||
name = "TypingIndicators",
|
||||
authors = persistentListOf(
|
||||
PluginManifest.Author(name = "rushii", id = 0L, hyperlink = false),
|
||||
),
|
||||
description = "Adds typing indicators to channels that people are currently typing in.",
|
||||
version = "1.1.0",
|
||||
updateUrl = "",
|
||||
changelog = "",
|
||||
changelogMedia = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.meowarex.rlmobile.ui.previews.screens.plugins
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreenContent
|
||||
import com.meowarex.rlmobile.ui.theme.ManagerTheme
|
||||
import com.meowarex.rlmobile.ui.util.emptyImmutableList
|
||||
|
||||
// This preview has interactable content that cannot be tested from an IDE preview
|
||||
|
||||
@Composable
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
private fun PluginsScreenNonePreview() {
|
||||
val filterState = remember { mutableStateOf("") }
|
||||
|
||||
ManagerTheme {
|
||||
PluginsScreenContent(
|
||||
searchText = filterState,
|
||||
setSearchText = filterState::value::set,
|
||||
isError = false,
|
||||
plugins = emptyImmutableList(),
|
||||
onPluginUninstall = {},
|
||||
onPluginChangelog = {},
|
||||
onPluginToggle = { name, enabled -> },
|
||||
safeMode = false,
|
||||
setSafeMode = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.meowarex.rlmobile.ui.screens.about
|
||||
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import com.meowarex.rlmobile.network.services.HttpService
|
||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||
|
||||
class AboutModel(
|
||||
@Suppress("unused") private val http: HttpService,
|
||||
) : StateScreenModel<AboutScreenState>(
|
||||
AboutScreenState.Loaded(emptyList<Contributor>().toUnsafeImmutable())
|
||||
) {
|
||||
fun fetchContributors() = Unit
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.meowarex.rlmobile.ui.screens.about
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.about.components.LeadContributor
|
||||
import com.meowarex.rlmobile.ui.util.paddings.*
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class AboutScreen : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "About"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = koinScreenModel<AboutModel>()
|
||||
|
||||
AboutScreenContent(state = model.state.collectAsState())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AboutScreenContent(state: State<AboutScreenState>) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.navigation_about)) },
|
||||
navigationIcon = { BackButton() },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = paddingValues
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
|
||||
.add(PaddingValues(vertical = 16.dp)),
|
||||
modifier = Modifier
|
||||
.padding(paddingValues.exclude(PaddingValuesSides.Bottom))
|
||||
.padding(horizontal = 14.dp),
|
||||
) {
|
||||
item(key = "PROJECT_HEADER") {
|
||||
ProjectHeader()
|
||||
}
|
||||
|
||||
item(key = "HEADER_DIVIDER") {
|
||||
TextDivider(
|
||||
text = stringResource(R.string.contributors_lead),
|
||||
modifier = Modifier.padding(top = 18.dp, bottom = 20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "MAIN_CONTRIBUTORS") {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
LeadContributor("meowarex", "Radiant Lyrics")
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "CONTRIBUTORS_DIVIDER") {
|
||||
TextDivider(
|
||||
text = stringResource(R.string.contributors),
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
when (val state = state.value) {
|
||||
AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
content = { CircularProgressIndicator() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 38.dp),
|
||||
)
|
||||
}
|
||||
|
||||
AboutScreenState.Failure -> item(key = "LOAD_FAILURE") {
|
||||
LoadFailure(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 38.dp)
|
||||
)
|
||||
}
|
||||
|
||||
is AboutScreenState.Loaded -> {
|
||||
items(state.contributors, key = { it.username }) { user ->
|
||||
ContributorCommitsItem(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.meowarex.rlmobile.ui.screens.about
|
||||
|
||||
import com.meowarex.rlmobile.network.models.Contributor
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
sealed interface AboutScreenState {
|
||||
data object Loading : AboutScreenState
|
||||
data object Failure : AboutScreenState
|
||||
data class Loaded(val contributors: ImmutableList<Contributor>) : AboutScreenState
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.meowarex.rlmobile.ui.screens.about.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
|
||||
@Composable
|
||||
fun LeadContributor(
|
||||
name: String,
|
||||
roles: String,
|
||||
username: String = name,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
onClick = { uriHandler.openUri("https://github.com/$username") },
|
||||
indication = ripple(bounded = false, radius = 90.dp),
|
||||
interactionSource = remember(::MutableInteractionSource)
|
||||
)
|
||||
.widthIn(min = 100.dp)
|
||||
) {
|
||||
SubcomposeAsyncImage(
|
||||
model = "https://github.com/$username.png",
|
||||
contentDescription = username,
|
||||
error = {
|
||||
Surface(
|
||||
content = {},
|
||||
tonalElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.shimmer(),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = 18.sp
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = roles,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.meowarex.rlmobile.ui.screens.componentopts
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.*
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import com.meowarex.rlmobile.ui.util.ScreenModelWithResult
|
||||
import com.meowarex.rlmobile.ui.util.ScreenResultKey
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ComponentOptionsModel(
|
||||
screenResultKey: ScreenResultKey,
|
||||
private val paths: PathManager,
|
||||
private val context: Application,
|
||||
) : ScreenModelWithResult<PatchComponent?>(screenResultKey) {
|
||||
val components = mutableStateListOf<PatchComponent>()
|
||||
var selected by mutableStateOf<PatchComponent?>(null)
|
||||
private set
|
||||
|
||||
fun selectComponent(component: PatchComponent?) {
|
||||
selected = component
|
||||
}
|
||||
|
||||
fun deleteComponent(component: PatchComponent) = screenModelScope.launchIO {
|
||||
component.getFile(paths).delete()
|
||||
|
||||
mainThread {
|
||||
components.remove(component)
|
||||
context.showToast(R.string.componentopts_deleted)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the available imported custom components for a specified type.
|
||||
*/
|
||||
suspend fun refreshComponents(type: PatchComponent.Type) {
|
||||
val files = when (type) {
|
||||
PatchComponent.Type.Injector -> paths.customInjectors()
|
||||
PatchComponent.Type.Patches -> paths.customSmaliPatches()
|
||||
}
|
||||
|
||||
// ${timestamp}_${componentVersion}.${componentFile.extension}
|
||||
val componentNameRegex = """^(\d+)_(\d+\.\d+.\d+)\.\w+$""".toRegex()
|
||||
|
||||
val newComponents = files.mapNotNull { file ->
|
||||
val match = componentNameRegex.find(file.name)
|
||||
?: return@mapNotNull null
|
||||
val (_, timestamp, version) = match.groupValues
|
||||
|
||||
PatchComponent(
|
||||
type = type,
|
||||
version = SemVer.parse(version),
|
||||
timestamp = Instant.fromEpochMilliseconds(timestamp.toLong()),
|
||||
)
|
||||
}.sortedByDescending { it.timestamp }
|
||||
|
||||
mainThread {
|
||||
components.clear()
|
||||
components.addAll(newComponents)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDispose() {
|
||||
screenModelScope.launch { setResult(selected) }
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.meowarex.rlmobile.ui.screens.componentopts
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.components.*
|
||||
import com.meowarex.rlmobile.ui.util.ScreenWithResult
|
||||
import com.meowarex.rlmobile.ui.util.paddings.*
|
||||
import com.meowarex.rlmobile.util.back
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Parcelize
|
||||
class ComponentOptionsScreen(
|
||||
/**
|
||||
* The type of custom component that this screen will be selecting.
|
||||
*/
|
||||
private val componentType: PatchComponent.Type,
|
||||
/**
|
||||
* A previously selected custom component that should be pre-selected on this screen.
|
||||
*/
|
||||
private val default: PatchComponent?,
|
||||
) : ScreenWithResult<PatchComponent?>(), Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "ComponentOptions-$componentType"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = koinScreenModel<ComponentOptionsModel> { parametersOf(this.resultKey) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
model.components.clear()
|
||||
withContext(Dispatchers.IO) {
|
||||
model.refreshComponents(componentType)
|
||||
}
|
||||
if (default in model.components) {
|
||||
model.selectComponent(default)
|
||||
}
|
||||
}
|
||||
|
||||
ComponentOptionsScreenContent(
|
||||
componentType = componentType,
|
||||
components = model.components.toImmutableList(),
|
||||
selected = model.selected,
|
||||
onSelectComponent = model::selectComponent,
|
||||
onDeleteComponent = model::deleteComponent,
|
||||
onBackPressed = { navigator.back(null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ComponentOptionsScreenContent(
|
||||
componentType: PatchComponent.Type,
|
||||
components: ImmutableList<PatchComponent>,
|
||||
selected: PatchComponent?,
|
||||
onSelectComponent: (PatchComponent?) -> Unit,
|
||||
onDeleteComponent: (PatchComponent) -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { ComponentOptionsAppBar(componentType = componentType) },
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = paddingValues
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
|
||||
.add(PaddingValues(16.dp)),
|
||||
modifier = Modifier
|
||||
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
|
||||
) {
|
||||
item(key = "NONE") {
|
||||
PatchComponentCardBase(
|
||||
selected = selected == null,
|
||||
onSelect = { onSelectComponent(null) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.componentopts_selected_none),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = components,
|
||||
contentType = { "COMPONENT" },
|
||||
key = { it },
|
||||
) { component ->
|
||||
PatchComponentCard(
|
||||
version = component.version,
|
||||
timestamp = component.timestamp,
|
||||
selected = selected == component,
|
||||
onSelect = { onSelectComponent(component) },
|
||||
onDelete = { onDeleteComponent(component) },
|
||||
)
|
||||
}
|
||||
|
||||
item("EXIT_BTN") {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onBackPressed,
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.meowarex.rlmobile.ui.screens.componentopts
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A custom component that was deployed to this device with
|
||||
* the `deployWithAdb` task and imported by Manager.
|
||||
*/
|
||||
@Immutable
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class PatchComponent(
|
||||
/**
|
||||
* The type of this custom component.
|
||||
*/
|
||||
val type: Type,
|
||||
/**
|
||||
* The build version of this custom component.
|
||||
*/
|
||||
val version: SemVer,
|
||||
/**
|
||||
* The time at which this custom component was deployed to the device and imported by manager.
|
||||
*/
|
||||
val timestamp: Instant,
|
||||
) : Parcelable {
|
||||
@Parcelize
|
||||
@Serializable
|
||||
enum class Type : Parcelable {
|
||||
@SerialName("injector")
|
||||
Injector,
|
||||
|
||||
@SerialName("patches")
|
||||
Patches,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the imported file where this custom component should be stored.
|
||||
* This is not guaranteed to exist.
|
||||
*/
|
||||
fun getFile(paths: PathManager): File {
|
||||
val dir = when (type) {
|
||||
Type.Injector -> paths.customInjectorsDir
|
||||
Type.Patches -> paths.customPatchesDir
|
||||
}
|
||||
val ext = when (type) {
|
||||
Type.Injector -> "dex"
|
||||
Type.Patches -> "zip"
|
||||
}
|
||||
|
||||
// ${timestamp}_${componentVersion}.${componentFile.extension}
|
||||
return dir.resolve("${timestamp.toEpochMilliseconds()}_$version.$ext")
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.meowarex.rlmobile.ui.screens.componentopts.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.BackButton
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
|
||||
@Composable
|
||||
fun ComponentOptionsAppBar(
|
||||
componentType: PatchComponent.Type,
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton() },
|
||||
title = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.componentopts_screen_title, componentType.name))
|
||||
Text(
|
||||
text = stringResource(R.string.componentopts_screen_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alpha(.7f),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.meowarex.rlmobile.ui.screens.componentopts.components
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.utils.SemVer
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Composable
|
||||
fun PatchComponentCard(
|
||||
version: SemVer,
|
||||
timestamp: Instant,
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PatchComponentCardBase(
|
||||
selected = selected,
|
||||
onSelect = onSelect,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_page),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.alpha(.8f)
|
||||
.padding(end = 4.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "v$version",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = DateUtils.getRelativeDateTimeString(
|
||||
/* c = */ LocalContext.current,
|
||||
/* time = */ timestamp.toEpochMilliseconds(),
|
||||
/* minResolution = */ DateUtils.SECOND_IN_MILLIS,
|
||||
/* transitionResolution = */ DateUtils.WEEK_IN_MILLIS,
|
||||
/* flags = */ DateUtils.FORMAT_ABBREV_ALL,
|
||||
).toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alpha(.6f),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f, fill = true))
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete_forever),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = stringResource(R.string.action_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PatchComponentCardBase(
|
||||
selected: Boolean,
|
||||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val interaction = remember(::MutableInteractionSource)
|
||||
|
||||
Surface(
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(
|
||||
interactionSource = interaction,
|
||||
role = Role.RadioButton,
|
||||
onClick = onSelect,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = onSelect,
|
||||
interactionSource = interaction,
|
||||
)
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.network.models.RLBuildInfo
|
||||
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
|
||||
import com.meowarex.rlmobile.network.utils.fold
|
||||
import com.meowarex.rlmobile.patcher.InstallMetadata
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
|
||||
class HomeModel(
|
||||
private val application: Application,
|
||||
private val github: RadiantLyricsGithubService,
|
||||
private val json: Json,
|
||||
) : ScreenModel {
|
||||
var installsState by mutableStateOf<InstallsState>(InstallsState.Fetching)
|
||||
private set
|
||||
|
||||
private val refreshingLock = Mutex()
|
||||
private var remoteDataJson: RLBuildInfo? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh(delay: Boolean = false) = screenModelScope.launchIO {
|
||||
if (refreshingLock.isLocked) return@launchIO
|
||||
|
||||
if (delay) {
|
||||
delay(250)
|
||||
|
||||
if (refreshingLock.isLocked)
|
||||
return@launchIO
|
||||
}
|
||||
|
||||
refreshingLock.withLock {
|
||||
val packages = fetchRadiantLyricsPackages()
|
||||
|
||||
val jobs = listOf(
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
fetchInstallations(packages)
|
||||
},
|
||||
screenModelScope.launch(Dispatchers.IO) {
|
||||
if (remoteDataJson == null)
|
||||
fetchRemoteData()
|
||||
}
|
||||
)
|
||||
|
||||
jobs.joinAll()
|
||||
mainThread { refreshInstallationsUpToDate(packages) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openApp(packageName: String) {
|
||||
val launchIntent = application.packageManager
|
||||
.getLaunchIntentForPackage(packageName)
|
||||
|
||||
if (launchIntent != null) {
|
||||
application.startActivity(launchIntent)
|
||||
} else {
|
||||
application.showToast(R.string.launch_app_fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun openAppInfo(packageName: String) {
|
||||
val launchIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.setData("package:$packageName".toUri())
|
||||
|
||||
application.startActivity(launchIntent)
|
||||
}
|
||||
|
||||
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen {
|
||||
val metadata = try {
|
||||
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
|
||||
val metadataFile = ZipReader(applicationInfo.publicSourceDir)
|
||||
.use { it.openEntry("rlmobile.json")?.read() }
|
||||
|
||||
metadataFile?.let { json.decodeFromStream<InstallMetadata>(it.inputStream()) }
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to parse Radiant Lyrics install metadata from package $packageName", t)
|
||||
null
|
||||
}
|
||||
|
||||
val patchOptions = metadata?.options
|
||||
?: PatchOptions.Default.copy(packageName = packageName)
|
||||
|
||||
return PatchOptionsScreen(prefilledOptions = patchOptions)
|
||||
}
|
||||
|
||||
private suspend fun fetchInstallations(packages: List<PackageInfo>) {
|
||||
mainThread {
|
||||
if (installsState !is InstallsState.Fetched)
|
||||
installsState = InstallsState.Fetching
|
||||
}
|
||||
|
||||
try {
|
||||
val packageManager = application.packageManager
|
||||
val rlMobileInstallations = packages.mapNotNull { pkg ->
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkg.versionCode
|
||||
val versionName = pkg.versionName ?: return@mapNotNull null
|
||||
val applicationInfo = pkg.applicationInfo ?: return@mapNotNull null
|
||||
|
||||
InstallData(
|
||||
name = packageManager.getApplicationLabel(applicationInfo).toString(),
|
||||
packageName = pkg.packageName,
|
||||
isUpToDate = isInstallationUpToDate(pkg),
|
||||
icon = packageManager
|
||||
.getApplicationIcon(applicationInfo)
|
||||
.toBitmap()
|
||||
.asImageBitmap()
|
||||
.let(::BitmapPainter),
|
||||
version = TidalVersion.Existing(
|
||||
type = TidalVersion.parseVersionType(versionCode),
|
||||
name = versionName.split("-")[0].trim(),
|
||||
code = versionCode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
mainThread {
|
||||
installsState = if (rlMobileInstallations.isNotEmpty()) {
|
||||
InstallsState.Fetched(data = rlMobileInstallations.toUnsafeImmutable())
|
||||
} else {
|
||||
InstallsState.None
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to query Radiant Lyrics installations", t)
|
||||
mainThread { installsState = InstallsState.Error }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshInstallationsUpToDate(packages: List<PackageInfo>) {
|
||||
val installations = mainThread { (installsState as? InstallsState.Fetched)?.data }
|
||||
?: return
|
||||
|
||||
try {
|
||||
val newInstallations = installations.map { data ->
|
||||
val packageInfo = packages.find { it.packageName == data.packageName }
|
||||
?: throw IllegalStateException("Checking up-to-date status for package that has not been fetched")
|
||||
|
||||
data.copy(isUpToDate = isInstallationUpToDate(packageInfo))
|
||||
}
|
||||
|
||||
mainThread { installsState = InstallsState.Fetched(data = newInstallations.toUnsafeImmutable()) }
|
||||
} catch (t: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to check installations up-to-date", t)
|
||||
mainThread { installsState = InstallsState.Error }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchRemoteData() {
|
||||
val release = try {
|
||||
github.getLatestRelease().let { response ->
|
||||
response.fold(
|
||||
success = { it },
|
||||
fail = {
|
||||
Log.w(BuildConfig.TAG, "Failed to fetch latest release", it)
|
||||
return
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to fetch remote data", t)
|
||||
mainThread { application.showToast(R.string.home_network_fail) }
|
||||
return
|
||||
}
|
||||
|
||||
val dataJsonUrl = release.assets
|
||||
.find { it.name == RadiantLyricsGithubService.DATA_JSON_ASSET_NAME }
|
||||
?.browserDownloadUrl
|
||||
?: run {
|
||||
Log.w(BuildConfig.TAG, "No data.json asset in latest release")
|
||||
return
|
||||
}
|
||||
|
||||
github.getBuildInfo(dataJsonUrl).fold(
|
||||
success = { remoteDataJson = it },
|
||||
fail = { Log.w(BuildConfig.TAG, "Failed to fetch remote build info", it) },
|
||||
)
|
||||
|
||||
if (remoteDataJson == null) {
|
||||
mainThread { application.showToast(R.string.home_network_fail) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchRadiantLyricsPackages(): List<PackageInfo> {
|
||||
return application.packageManager
|
||||
.getInstalledPackages(PackageManager.GET_META_DATA)
|
||||
.filter {
|
||||
it.applicationInfo?.metaData?.containsKey("isRadiantLyrics") == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInstallationUpToDate(pkg: PackageInfo): Boolean? {
|
||||
val remoteBuildData = remoteDataJson ?: return null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val versionCode = pkg.versionCode
|
||||
|
||||
if (remoteBuildData.tidalVersionCode != versionCode) return false
|
||||
|
||||
val apkPath = pkg.applicationInfo?.publicSourceDir ?: return false
|
||||
val installMetadata = try {
|
||||
val metadataFile = ZipReader(apkPath).use { it.openEntry("rlmobile.json")?.read() }
|
||||
?: return false
|
||||
|
||||
json.decodeFromStream<InstallMetadata>(metadataFile.inputStream())
|
||||
} catch (t: Throwable) {
|
||||
Log.d(BuildConfig.TAG, "Failed to parse Radiant Lyrics InstallMetadata from package ${pkg.packageName}", t)
|
||||
return false
|
||||
}
|
||||
|
||||
if (installMetadata.options.customPatches != null) return true
|
||||
|
||||
return remoteBuildData.patchesVersion == installMetadata.patchesVersion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
|
||||
package com.meowarex.rlmobile.ui.screens.home
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.LoadFailure
|
||||
import com.meowarex.rlmobile.ui.components.ProjectHeader
|
||||
import com.meowarex.rlmobile.ui.screens.home.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.PluginsScreen
|
||||
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
|
||||
import com.meowarex.rlmobile.ui.util.paddings.exclude
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class HomeScreen : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "Home"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val scope = rememberCoroutineScope()
|
||||
val model = koinScreenModel<HomeModel>()
|
||||
|
||||
// Refresh installations list when the screen changes or activity resumes
|
||||
LifecycleResumeEffect(Unit) {
|
||||
model.refresh(delay = true)
|
||||
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { HomeAppBar() },
|
||||
) { padding ->
|
||||
when (val state = model.installsState) {
|
||||
is InstallsState.Fetched -> HomeScreenLoadedContent(
|
||||
state = state,
|
||||
padding = padding,
|
||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||
onUpdate = {
|
||||
scope.launchIO {
|
||||
val screen = model.createPrefilledPatchOptsScreen(it)
|
||||
mainThread { navigator.push(screen) }
|
||||
}
|
||||
},
|
||||
onOpenApp = model::openApp,
|
||||
onOpenAppInfo = model::openAppInfo,
|
||||
onOpenPlugins = { navigator.push(PluginsScreen()) }, // TODO: install-specific plugins
|
||||
)
|
||||
|
||||
InstallsState.Fetching -> HomeScreenLoadingContent(padding = padding)
|
||||
|
||||
InstallsState.None -> HomeScreenNoneContent(
|
||||
padding = padding,
|
||||
onClickInstall = { navigator.pushOnce(PatchOptionsScreen()) },
|
||||
)
|
||||
|
||||
InstallsState.Error -> HomeScreenFailureContent(padding = padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenLoadingContent(padding: PaddingValues) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
ProjectHeader()
|
||||
|
||||
AnimatedVisibility(
|
||||
visibleState = remember { MutableTransitionState(false) }.apply { targetState = true },
|
||||
enter = fadeIn(animationSpec = tween(durationMillis = 800)),
|
||||
exit = ExitTransition.None,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
content = { CircularProgressIndicator() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenLoadedContent(
|
||||
state: InstallsState.Fetched,
|
||||
padding: PaddingValues,
|
||||
onClickInstall: () -> Unit,
|
||||
onUpdate: (packageName: String) -> Unit,
|
||||
onOpenApp: (packageName: String) -> Unit,
|
||||
onOpenAppInfo: (packageName: String) -> Unit,
|
||||
onOpenPlugins: (packageName: String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = padding
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding.exclude(PaddingValuesSides.Bottom))
|
||||
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
item(key = "PROJECT_HEADER") {
|
||||
ProjectHeader()
|
||||
}
|
||||
|
||||
item(key = "ADD_INSTALL_BUTTON") {
|
||||
InstallButton(
|
||||
secondaryInstall = true,
|
||||
onClick = onClickInstall,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 4.dp)
|
||||
.height(50.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
items(state.data, key = { it.packageName }) { item ->
|
||||
InstalledItemCard(
|
||||
data = item,
|
||||
onUpdate = { onUpdate(item.packageName) },
|
||||
onOpenApp = { onOpenApp(item.packageName) },
|
||||
onOpenInfo = { onOpenAppInfo(item.packageName) },
|
||||
onOpenPlugins = { onOpenPlugins(item.packageName) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenNoneContent(
|
||||
padding: PaddingValues,
|
||||
onClickInstall: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
ProjectHeader()
|
||||
|
||||
InstallButton(
|
||||
secondaryInstall = false,
|
||||
onClick = onClickInstall,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.height(height = 50.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 80.dp)
|
||||
) {
|
||||
Text(
|
||||
text = """ /ᐠﹷ ‸ ﹷ ᐟ\ノ""",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
.copy(fontSize = 38.sp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.installs_no_installs),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(start = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreenFailureContent(
|
||||
padding: PaddingValues,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
ProjectHeader()
|
||||
LoadFailure(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import com.meowarex.rlmobile.ui.util.TidalVersion
|
||||
|
||||
@Immutable
|
||||
data class InstallData(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val version: TidalVersion,
|
||||
val icon: BitmapPainter,
|
||||
val isUpToDate: Boolean?,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
sealed interface InstallsState {
|
||||
data object None : InstallsState
|
||||
data object Error : InstallsState
|
||||
data object Fetching : InstallsState
|
||||
data class Fetched(val data: ImmutableList<InstallData>) : InstallsState
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home.components
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.about.AboutScreen
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogsListScreen
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun HomeAppBar() {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
actions = {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
IconButton(onClick = { navigator?.push(AboutScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_info),
|
||||
contentDescription = stringResource(R.string.navigation_about),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { navigator?.push(LogsListScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_receipt),
|
||||
contentDescription = stringResource(R.string.navigation_logs),
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_settings),
|
||||
contentDescription = stringResource(R.string.navigation_settings),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
import com.valentinilk.shimmer.*
|
||||
|
||||
private val shimmerTheme = defaultShimmerTheme.copy(
|
||||
shimmerWidth = 150.dp,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = shimmerSpec(
|
||||
durationMillis = 2000,
|
||||
easing = LinearEasing,
|
||||
delayMillis = 3500,
|
||||
),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(1000),
|
||||
),
|
||||
blendMode = BlendMode.Lighten,
|
||||
shaderColors = listOf(
|
||||
Color.White.copy(alpha = 0.00f),
|
||||
Color.White.copy(alpha = 0.50f),
|
||||
Color.White.copy(alpha = 1.00f),
|
||||
Color.White.copy(alpha = 0.50f),
|
||||
Color.White.copy(alpha = 0.00f),
|
||||
),
|
||||
shaderColorStops = listOf(
|
||||
0.0f,
|
||||
0.25f,
|
||||
0.5f,
|
||||
0.75f,
|
||||
1.0f,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InstallButton(
|
||||
enabled: Boolean = true,
|
||||
secondaryInstall: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalShimmerTheme provides shimmerTheme
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
shape = RectangleShape,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = if (secondaryInstall) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
),
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.thenIf(!secondaryInstall) { shimmer() }
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(end = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_add),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.action_add_install),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.meowarex.rlmobile.ui.screens.home.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.SegmentedButton
|
||||
import com.meowarex.rlmobile.ui.components.VersionDisplay
|
||||
import com.meowarex.rlmobile.ui.screens.home.InstallData
|
||||
|
||||
@Composable
|
||||
fun InstalledItemCard(
|
||||
data: InstallData,
|
||||
onUpdate: () -> Unit,
|
||||
onOpenApp: () -> Unit,
|
||||
onOpenInfo: () -> Unit,
|
||||
onOpenPlugins: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ElevatedCard(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
elevation = CardDefaults.elevatedCardElevation(
|
||||
defaultElevation = 3.dp,
|
||||
),
|
||||
modifier = modifier
|
||||
.width(IntrinsicSize.Max),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
modifier = Modifier.padding(20.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
painter = data.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = data.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = .94f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = data.packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(start = 1.dp)
|
||||
.offset(y = (-2).dp)
|
||||
.alpha(.7f)
|
||||
.basicMarquee(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f, fill = true))
|
||||
|
||||
VersionDisplay(
|
||||
version = data.version,
|
||||
prefix = { append("v") },
|
||||
modifier = Modifier
|
||||
.alpha(.6f)
|
||||
.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.clip(MaterialTheme.shapes.large),
|
||||
) {
|
||||
SegmentedButton(
|
||||
icon = painterResource(R.drawable.ic_extension),
|
||||
text = stringResource(R.string.plugins_title),
|
||||
onClick = onOpenPlugins,
|
||||
)
|
||||
SegmentedButton(
|
||||
icon = painterResource(R.drawable.ic_info),
|
||||
text = stringResource(R.string.action_open_info),
|
||||
onClick = onOpenInfo,
|
||||
)
|
||||
|
||||
// If the up-to-date status cannot be determined, assume it is up-to-date
|
||||
if (data.isUpToDate ?: true) {
|
||||
SegmentedButton(
|
||||
icon = painterResource(R.drawable.ic_launch),
|
||||
text = stringResource(R.string.action_launch),
|
||||
onClick = onOpenApp,
|
||||
)
|
||||
} else {
|
||||
val warningColor = Color(0xFFFFBB33)
|
||||
|
||||
SegmentedButton(
|
||||
icon = painterResource(R.drawable.ic_update),
|
||||
text = stringResource(R.string.action_update),
|
||||
iconColor = warningColor,
|
||||
textColor = warningColor,
|
||||
onClick = onUpdate,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.meowarex.rlmobile.ui.screens.log
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.core.screen.ScreenKey
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.InstallLogData
|
||||
import com.meowarex.rlmobile.ui.components.Label
|
||||
import com.meowarex.rlmobile.ui.screens.log.components.LogAppBar
|
||||
import com.meowarex.rlmobile.ui.screens.log.components.LogTextArea
|
||||
import com.meowarex.rlmobile.util.back
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Parcelize
|
||||
class LogScreen(private val installId: String) : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key: ScreenKey
|
||||
get() = "LogScreen-$installId"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = koinScreenModel<LogScreenModel> { parametersOf(installId) }
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
if (model.shouldCloseScreen) {
|
||||
navigator.back(currentActivity = null)
|
||||
}
|
||||
|
||||
model.data?.let {
|
||||
LogScreenContent(
|
||||
data = it,
|
||||
onExportLog = model::saveLog,
|
||||
onShareLog = model::shareLog,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogScreenContent(
|
||||
data: InstallLogData,
|
||||
onExportLog: () -> Unit,
|
||||
onShareLog: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LogAppBar(
|
||||
onExportLog = onExportLog,
|
||||
onShareLog = onShareLog,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.padding(vertical = 10.dp, horizontal = 22.dp)
|
||||
) {
|
||||
item("INSTALL_INFO") {
|
||||
Label(
|
||||
name = stringResource(R.string.log_section_install_info),
|
||||
description = null,
|
||||
) {
|
||||
LogTextArea(
|
||||
text = """
|
||||
Installation ID: ${data.id}
|
||||
Installation Date: ${data.installDate}
|
||||
""".trimIndent(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item("ENVIRONMENT_INFO") {
|
||||
Label(
|
||||
name = stringResource(R.string.log_section_env_info),
|
||||
description = null,
|
||||
) {
|
||||
LogTextArea(
|
||||
text = data.environmentInfo,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.errorStacktrace != null) {
|
||||
item("ERROR_STACKTRACE") {
|
||||
Label(
|
||||
name = stringResource(R.string.log_section_error),
|
||||
description = null,
|
||||
) {
|
||||
LogTextArea(
|
||||
text = data.errorStacktrace,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item("LOG") {
|
||||
Label(
|
||||
name = stringResource(R.string.log_section_log),
|
||||
description = null,
|
||||
) {
|
||||
LogTextArea(
|
||||
text = data.installationLog,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.meowarex.rlmobile.ui.screens.log
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.content.FileProvider
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.InstallLogData
|
||||
import com.meowarex.rlmobile.manager.InstallLogManager
|
||||
import com.meowarex.rlmobile.util.*
|
||||
|
||||
class LogScreenModel(
|
||||
private val installId: String,
|
||||
private val logs: InstallLogManager,
|
||||
private val application: Application,
|
||||
) : ScreenModel {
|
||||
var shouldCloseScreen by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var data by mutableStateOf<InstallLogData?>(null)
|
||||
private set
|
||||
|
||||
init {
|
||||
loadLogData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the log data into a file and writes it to the downloads folder.
|
||||
*/
|
||||
fun saveLog() = screenModelScope.launchIO {
|
||||
val data = data ?: return@launchIO
|
||||
|
||||
val formattedDate = data.getFormattedInstallDate()
|
||||
val content = data.getLogFileContents()
|
||||
|
||||
application.saveFile("RadiantLyrics Install $formattedDate.log", content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the log to internal cache and launches a share intent of the log file.
|
||||
*/
|
||||
fun shareLog() {
|
||||
val data = data ?: return
|
||||
val formattedDate = data.getFormattedInstallDate()
|
||||
val formattedName = "RadiantLyrics Install $formattedDate.log"
|
||||
val content = data.getLogFileContents()
|
||||
|
||||
val file = application.cacheDir.resolve(formattedName)
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
/* context = */ application,
|
||||
/* authority = */ "${BuildConfig.APPLICATION_ID}.provider",
|
||||
/* file = */ file,
|
||||
/* displayName = */ formattedName,
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/*")
|
||||
.putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.let {
|
||||
Intent.createChooser(
|
||||
/* target = */ it,
|
||||
/* title = */ application.getString(R.string.log_action_share),
|
||||
)
|
||||
}
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
try {
|
||||
file.writeText(content)
|
||||
file.deleteOnExit()
|
||||
application.startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to share log", t)
|
||||
application.showToast(R.string.status_failed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLogData() = screenModelScope.launchIO {
|
||||
val result = logs.fetchInstallData(installId)
|
||||
|
||||
mainThread {
|
||||
if (result != null) {
|
||||
data = result
|
||||
} else {
|
||||
shouldCloseScreen = true
|
||||
application.showToast(R.string.network_load_fail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.meowarex.rlmobile.ui.screens.log.components
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.BackButton
|
||||
|
||||
@Composable
|
||||
fun LogAppBar(
|
||||
onExportLog: () -> Unit,
|
||||
onShareLog: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.log_title)) },
|
||||
navigationIcon = { BackButton() },
|
||||
actions = {
|
||||
IconButton(onClick = onExportLog) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_save),
|
||||
contentDescription = stringResource(R.string.log_action_export),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onShareLog) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_share),
|
||||
contentDescription = stringResource(R.string.log_action_share),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.meowarex.rlmobile.ui.screens.log.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.meowarex.rlmobile.ui.util.horizontalScrollbar
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
|
||||
@Composable
|
||||
fun LogTextArea(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier.Companion,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollable by remember { derivedStateOf { scrollState.maxValue > 0 } }
|
||||
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
lineHeight = 18.sp,
|
||||
fontFamily = FontFamily.Companion.Monospace,
|
||||
softWrap = false,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 14.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
.horizontalScrollbar(scrollState)
|
||||
.thenIf(scrollable) { padding(bottom = 10.dp) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.core.screen.ScreenKey
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.ui.screens.log.LogScreen
|
||||
import com.meowarex.rlmobile.ui.screens.logs.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.logs.components.dialogs.DeleteLogsDialog
|
||||
import com.meowarex.rlmobile.ui.util.paddings.PaddingValuesSides
|
||||
import com.meowarex.rlmobile.ui.util.paddings.exclude
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class LogsListScreen : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key: ScreenKey
|
||||
get() = "LogsScreen"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = koinScreenModel<LogsListScreenModel>()
|
||||
|
||||
var showWipeConfirmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showWipeConfirmDialog) {
|
||||
DeleteLogsDialog(
|
||||
onConfirm = {
|
||||
showWipeConfirmDialog = false
|
||||
model.deleteLogs()
|
||||
},
|
||||
onDismiss = {
|
||||
showWipeConfirmDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LogsScreenContent(
|
||||
logs = model.logEntries,
|
||||
onOpenLog = { navigator.push(LogScreen(installId = it)) },
|
||||
onDeleteLogs = { showWipeConfirmDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogsScreenContent(
|
||||
logs: SnapshotStateList<LogEntry>,
|
||||
onOpenLog: (id: String) -> Unit,
|
||||
onDeleteLogs: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LogsListAppBar(
|
||||
onDeleteLogs = onDeleteLogs,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = paddingValues.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
|
||||
modifier = Modifier
|
||||
.padding(paddingValues.exclude(PaddingValuesSides.Bottom))
|
||||
.padding(vertical = 12.dp, horizontal = 22.dp)
|
||||
) {
|
||||
if (logs.isEmpty()) {
|
||||
item(key = "EMPTY") {
|
||||
LogsNone(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = logs,
|
||||
contentType = { "LOG" },
|
||||
key = { it.id },
|
||||
) { data ->
|
||||
LogEntryCard(
|
||||
data = data,
|
||||
onClick = remember(onOpenLog, data.id) { { onOpenLog(data.id) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs
|
||||
|
||||
import android.app.Application
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.InstallLogManager
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class LogsListScreenModel(
|
||||
private val logsManager: InstallLogManager,
|
||||
private val application: Application,
|
||||
) : ScreenModel {
|
||||
/**
|
||||
* All the loaded log entries sorted descending by creation date.
|
||||
*/
|
||||
val logEntries = mutableStateListOf<LogEntry>()
|
||||
|
||||
init {
|
||||
loadLogsList()
|
||||
}
|
||||
|
||||
fun deleteLogs() = screenModelScope.launchIO {
|
||||
logsManager.deleteAllEntries()
|
||||
|
||||
mainThread {
|
||||
logEntries.clear()
|
||||
application.showToast(R.string.logs_status_delete_success)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLogsList() = screenModelScope.launchIO {
|
||||
for (installId in logsManager.fetchInstallDataEntries()) {
|
||||
val data = logsManager.fetchInstallData(id = installId)
|
||||
?: continue
|
||||
|
||||
val entry = LogEntry(
|
||||
id = data.id,
|
||||
isError = data.isError,
|
||||
installDate = DateUtils.getRelativeDateTimeString(
|
||||
/* c = */ application,
|
||||
/* time = */ data.installDate.toEpochMilliseconds(),
|
||||
/* minResolution = */ DateUtils.SECOND_IN_MILLIS,
|
||||
/* transitionResolution = */ DateUtils.WEEK_IN_MILLIS,
|
||||
/* flags = */ DateUtils.FORMAT_ABBREV_ALL,
|
||||
).toString(),
|
||||
durationSecs = data.installDuration.inWholeMilliseconds / 1000f,
|
||||
stacktracePreview = data.errorStacktrace
|
||||
?.splitToSequence('\n')
|
||||
?.take(3)
|
||||
?.toImmutableList(),
|
||||
)
|
||||
|
||||
mainThread { logEntries += entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LogEntry(
|
||||
val id: String,
|
||||
val isError: Boolean,
|
||||
val installDate: String,
|
||||
val durationSecs: Float,
|
||||
val stacktracePreview: ImmutableList<String>?,
|
||||
)
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.ui.screens.logs.LogEntry
|
||||
import com.meowarex.rlmobile.ui.screens.patching.components.StepStateIcon
|
||||
import com.meowarex.rlmobile.ui.screens.patching.components.TimeElapsed
|
||||
|
||||
@Composable
|
||||
fun LogEntryCard(
|
||||
data: LogEntry,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
|
||||
ElevatedCard(
|
||||
shape = RectangleShape,
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.clip(RoundedCornerShape(topStart = 6.dp, 12.0.dp, bottomStart = 6.dp, bottomEnd = 12.0.dp))
|
||||
.drawWithCache {
|
||||
val color = when (data.isError) {
|
||||
true -> errorColor
|
||||
false -> Color(0xFF59B463)
|
||||
}
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
color = color,
|
||||
alpha = .8f,
|
||||
topLeft = Offset.Zero,
|
||||
size = Size(4.dp.toPx(), size.height),
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 18.dp, horizontal = 22.dp),
|
||||
) {
|
||||
StepStateIcon(
|
||||
state = if (data.isError) StepState.Error else StepState.Success,
|
||||
size = 24.dp,
|
||||
)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = when (data.isError) {
|
||||
true -> stringResource(R.string.status_failed)
|
||||
false -> stringResource(R.string.status_success)
|
||||
},
|
||||
)
|
||||
|
||||
Text(
|
||||
text = data.installDate,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.alpha(.6f),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f, fill = true))
|
||||
|
||||
TimeElapsed(
|
||||
seconds = data.durationSecs,
|
||||
modifier = Modifier.alpha(.9f),
|
||||
)
|
||||
}
|
||||
|
||||
if (data.stacktracePreview != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 26.dp, end = 20.dp, bottom = 18.dp)
|
||||
// https://stackoverflow.com/a/76270310/13964629
|
||||
.graphicsLayer(
|
||||
alpha = .95f,
|
||||
compositingStrategy = CompositingStrategy.Offscreen,
|
||||
)
|
||||
.drawWithContent {
|
||||
val colors = listOf(Color.Black, Color.Black, Color.Transparent)
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(colors),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
) {
|
||||
// The stacktrace is separated into multiple Text elements because ellipsis is not supported per-line
|
||||
for (line in data.stacktracePreview) key(line) {
|
||||
Text(
|
||||
text = line,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 18.sp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Companion.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs.components
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.BackButton
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun LogsListAppBar(
|
||||
onDeleteLogs: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton() },
|
||||
title = { Text(stringResource(R.string.logs_title)) },
|
||||
actions = {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
IconButton(onClick = onDeleteLogs) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete_forever),
|
||||
contentDescription = stringResource(R.string.logs_action_delete_all)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_settings),
|
||||
contentDescription = stringResource(R.string.navigation_settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun LogsNone(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_reciept_off),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.logs_none),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.alpha(.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.meowarex.rlmobile.ui.screens.logs.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun DeleteLogsDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete_forever),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.logs_wipe_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.logs_wipe_desc),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.ui.components.MainActionButton
|
||||
import com.meowarex.rlmobile.ui.components.Wakelock
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
|
||||
import com.meowarex.rlmobile.ui.components.dialogs.NetworkWarningDialog
|
||||
import com.meowarex.rlmobile.ui.screens.log.LogScreen
|
||||
import com.meowarex.rlmobile.ui.screens.patching.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.theme.customColors
|
||||
import com.meowarex.rlmobile.ui.util.paddings.*
|
||||
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
import com.meowarex.rlmobile.util.back
|
||||
import com.meowarex.rlmobile.util.isIgnoringBatteryOptimizations
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
val VERTICAL_PADDING: Dp = 18.dp
|
||||
|
||||
@Parcelize
|
||||
class PatchingScreen(
|
||||
/**
|
||||
* User-selected patching options. This may originate from [PatchOptionsScreen] or
|
||||
* from an existing installation, to update it.
|
||||
*/
|
||||
private val options: PatchOptions,
|
||||
) : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "Patching"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
val model = koinScreenModel<PatchingScreenModel> { parametersOf(options) }
|
||||
|
||||
val state by model.state.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val showMinimizationWarning = rememberSaveable { !context.isIgnoringBatteryOptimizations() }
|
||||
|
||||
// Exit warning dialog (dismiss itself if install process state changes, esp. for Success)
|
||||
var showAbortWarning by rememberSaveable(model.state.collectAsState().value) { mutableStateOf(false) }
|
||||
|
||||
// The currently expanded step group on this screen
|
||||
var expandedGroup by rememberSaveable { mutableStateOf<StepGroup?>(StepGroup.Prepare) }
|
||||
|
||||
// Only show exit warning if currently working
|
||||
val onTryExit: () -> Unit = remember {
|
||||
{
|
||||
// Show cancellation if currently running
|
||||
if (state == PatchingScreenState.Working && !model.devMode) {
|
||||
showAbortWarning = true
|
||||
}
|
||||
// Go home directly if install was successful
|
||||
else if (state is PatchingScreenState.Success) {
|
||||
navigator.popUntilRoot()
|
||||
}
|
||||
// Go back to the patch options screen
|
||||
else {
|
||||
navigator.back(currentActivity = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent screen from turning off while working
|
||||
Wakelock(active = state is PatchingScreenState.Working)
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when (state) {
|
||||
// Go home directly if screen model mandates so (usually caused by cancelled PackageInstaller dialog)
|
||||
PatchingScreenState.CloseScreen -> navigator.popUntilRoot()
|
||||
|
||||
// Close all groups when successfully finished everything
|
||||
PatchingScreenState.Success -> {
|
||||
expandedGroup = null
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
|
||||
if (model.showNetworkWarningDialog) {
|
||||
NetworkWarningDialog(
|
||||
onConfirm = { neverShow ->
|
||||
model.hideNetworkWarning(neverShow)
|
||||
model.install()
|
||||
},
|
||||
onDismiss = { neverShow ->
|
||||
model.hideNetworkWarning(neverShow)
|
||||
navigator.pop()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showAbortWarning) {
|
||||
InstallerAbortDialog(
|
||||
onDismiss = { showAbortWarning = false },
|
||||
onConfirm = {
|
||||
navigator.back(currentActivity = null)
|
||||
model.cancelInstall()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BackHandler(onBack = onTryExit)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { PatchingAppBar(onTryExit) },
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING),
|
||||
modifier = Modifier
|
||||
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp),
|
||||
contentPadding = paddingValues
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
|
||||
.add(PaddingValues(bottom = 25.dp)),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
item(key = "MINIMIZATION_WARNING") {
|
||||
BannerSection(visible = showMinimizationWarning && !state.isFinished) {
|
||||
TextBanner(
|
||||
text = stringResource(R.string.installer_banner_minimization),
|
||||
icon = painterResource(R.drawable.ic_warning),
|
||||
iconColor = MaterialTheme.customColors.onWarningContainer,
|
||||
outlineColor = MaterialTheme.customColors.warning,
|
||||
containerColor = MaterialTheme.customColors.warningContainer,
|
||||
modifier = Modifier
|
||||
.padding(bottom = VERTICAL_PADDING)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "FAILED_BANNER") {
|
||||
BannerSection(visible = state is PatchingScreenState.Failed) {
|
||||
val handler = LocalUriHandler.current
|
||||
|
||||
TextBanner(
|
||||
text = stringResource(R.string.installer_banner_failure),
|
||||
icon = painterResource(R.drawable.ic_warning),
|
||||
iconColor = MaterialTheme.colorScheme.error,
|
||||
outlineColor = null,
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
onClick = { handler.openUri("https://tidal.gg/${BuildConfig.SUPPORT_SERVER}") },
|
||||
modifier = Modifier
|
||||
.padding(bottom = VERTICAL_PADDING)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "INSTALLED_BANNER") {
|
||||
BannerSection(visible = state is PatchingScreenState.Success) {
|
||||
TextBanner(
|
||||
text = stringResource(R.string.installer_banner_success),
|
||||
icon = painterResource(R.drawable.ic_check_circle),
|
||||
iconColor = Color(0xFF59B463),
|
||||
outlineColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier
|
||||
.padding(bottom = VERTICAL_PADDING)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for ((group, steps) in model.steps?.entries ?: persistentListOf()) {
|
||||
item(key = System.identityHashCode(group)) {
|
||||
StepGroupCard(
|
||||
name = stringResource(group.localizedName),
|
||||
subSteps = steps,
|
||||
isExpanded = expandedGroup == group,
|
||||
onExpand = { expandedGroup = group },
|
||||
modifier = Modifier
|
||||
.padding(bottom = VERTICAL_PADDING)
|
||||
.fillMaxWidth()
|
||||
.thenIf(state is PatchingScreenState.Success) { alpha(.5f) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "BUTTONS") {
|
||||
var cacheCleared by rememberSaveable { mutableStateOf(false) }
|
||||
val filteredState by remember { model.state.filter { it.isProgressChange } }
|
||||
.collectAsState(initial = PatchingScreenState.Working)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = filteredState != PatchingScreenState.Working,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically { it * -2 },
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING / 2),
|
||||
) {
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier
|
||||
.padding(bottom = VERTICAL_PADDING / 2)
|
||||
)
|
||||
|
||||
when (filteredState) {
|
||||
PatchingScreenState.Working -> {}
|
||||
PatchingScreenState.CloseScreen -> error("unreachable")
|
||||
|
||||
PatchingScreenState.Success -> {
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.action_launch),
|
||||
icon = painterResource(R.drawable.ic_launch),
|
||||
onClick = model::launchApp,
|
||||
)
|
||||
}
|
||||
|
||||
is PatchingScreenState.Failed -> {
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.action_retry_install),
|
||||
icon = painterResource(R.drawable.ic_refresh),
|
||||
onClick = model::install,
|
||||
)
|
||||
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.action_open_error_log),
|
||||
icon = painterResource(R.drawable.ic_launch),
|
||||
onClick = { navigator.push(LogScreen(installId = model.getCurrentInstallId()!!)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MainActionButton(
|
||||
text = stringResource(R.string.settings_clear_cache),
|
||||
icon = painterResource(R.drawable.ic_delete_forever),
|
||||
enabled = !cacheCleared,
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
onClick = {
|
||||
cacheCleared = true
|
||||
model.clearCache()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "FUN_FACT") {
|
||||
FunFact(
|
||||
text = stringResource(model.funFact),
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerSection(
|
||||
visible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically(),
|
||||
modifier = modifier
|
||||
.padding(bottom = VERTICAL_PADDING),
|
||||
) {
|
||||
Column {
|
||||
content()
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.*
|
||||
import com.meowarex.rlmobile.patcher.TidalPatchRunner
|
||||
import com.meowarex.rlmobile.patcher.StepRunner
|
||||
import com.meowarex.rlmobile.patcher.steps.StepGroup
|
||||
import com.meowarex.rlmobile.patcher.steps.base.*
|
||||
import com.meowarex.rlmobile.patcher.steps.install.InstallStep
|
||||
import com.meowarex.rlmobile.patcher.util.InsufficientStorageException
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
|
||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.UUID
|
||||
import kotlin.time.*
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class PatchingScreenModel(
|
||||
private val options: PatchOptions,
|
||||
private val paths: PathManager,
|
||||
private val prefs: PreferencesManager,
|
||||
private val application: Application,
|
||||
private val installLogs: InstallLogManager,
|
||||
) : StateScreenModel<PatchingScreenState>(PatchingScreenState.Working) {
|
||||
private var installId: String? = null
|
||||
private var startTime: Instant? = null
|
||||
private var runnerJob: Job? = null
|
||||
private var stepRunner: StepRunner? = null
|
||||
|
||||
val devMode get() = prefs.devMode
|
||||
|
||||
var showNetworkWarningDialog by mutableStateOf(!alreadyShownNetworkWarning && application.isNetworkDangerous())
|
||||
private set
|
||||
|
||||
var steps by mutableStateOf<ImmutableMap<StepGroup, ImmutableList<Step>>?>(null)
|
||||
private set
|
||||
|
||||
@get:StringRes
|
||||
var funFact by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
init {
|
||||
if (!prefs.showNetworkWarning)
|
||||
showNetworkWarningDialog = false
|
||||
|
||||
if (!showNetworkWarningDialog)
|
||||
install()
|
||||
|
||||
// Rotate fun facts every so often
|
||||
screenModelScope.launch {
|
||||
while (true) {
|
||||
funFact = FUN_FACTS.random()
|
||||
delay(8.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hideNetworkWarning(neverShow: Boolean) {
|
||||
showNetworkWarningDialog = false
|
||||
alreadyShownNetworkWarning = true
|
||||
prefs.showNetworkWarning = !neverShow
|
||||
}
|
||||
|
||||
fun launchApp() {
|
||||
if (state.value !is PatchingScreenState.Success)
|
||||
return
|
||||
|
||||
val launchIntent = application.packageManager
|
||||
.getLaunchIntentForPackage(options.packageName)
|
||||
|
||||
if (launchIntent != null) {
|
||||
application.startActivity(launchIntent)
|
||||
} else {
|
||||
application.showToast(R.string.launch_app_fail)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCache() = screenModelScope.launchIO {
|
||||
paths.clearCache()
|
||||
mainThread { application.showToast(R.string.action_cleared_cache) }
|
||||
}
|
||||
|
||||
fun getCurrentInstallId(): String? = installId
|
||||
|
||||
fun cancelInstall() = screenModelScope.launchIO {
|
||||
runnerJob?.cancel("Manual cancellation")
|
||||
|
||||
// Delete any in-progress downloads to be safe
|
||||
stepRunner?.also { container ->
|
||||
val incompleteDownloadStep = container.steps
|
||||
.filterIsInstance<DownloadStep<*>>()
|
||||
.lastOrNull { it.state == StepState.Running }
|
||||
|
||||
incompleteDownloadStep?.getStoredFile(container)?.delete()
|
||||
}
|
||||
|
||||
paths.patchingWorkingDir.deleteRecursively()
|
||||
}
|
||||
|
||||
fun install() = screenModelScope.launchBlock {
|
||||
runnerJob?.cancel("Manual cancellation")
|
||||
mainThread { steps = null }
|
||||
|
||||
@SuppressLint("MemberExtensionConflict")
|
||||
installId = UUID.randomUUID().toString()
|
||||
startTime = Clock.System.now()
|
||||
mutableState.value = PatchingScreenState.Working
|
||||
|
||||
runnerJob = screenModelScope.launch(Dispatchers.Default) {
|
||||
Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo())
|
||||
|
||||
try {
|
||||
startPatchRunner()
|
||||
} catch (_: CancellationException) {
|
||||
Log.w(BuildConfig.TAG, "Installation was cancelled before completion")
|
||||
mutableState.value = PatchingScreenState.CloseScreen
|
||||
} catch (error: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to orchestrate patch runner", error)
|
||||
mutableState.value = PatchingScreenState.Failed(installId = installId!!)
|
||||
installLogs.storeInstallData(
|
||||
id = installId!!,
|
||||
installDate = startTime!!,
|
||||
installDuration = Duration.ZERO,
|
||||
options = options,
|
||||
log = "- Failed to initialize patch runner",
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startPatchRunner() {
|
||||
val runner = TidalPatchRunner(options)
|
||||
.also { stepRunner = it }
|
||||
|
||||
val newSteps = runner.steps.groupBy { it.group }
|
||||
.mapValues { it.value.toUnsafeImmutable() }
|
||||
.toUnsafeImmutable()
|
||||
mainThread { steps = newSteps }
|
||||
|
||||
// Intentionally delay to show the state change of the first step when it runs in the UI.
|
||||
// Without this, on a fast internet connection the step just immediately shows as "Success".
|
||||
delay(400)
|
||||
|
||||
// Execute all the steps and catch any errors
|
||||
val error = when (val error = runner.executeAll()) {
|
||||
null -> {
|
||||
// If install step is marked skipped then the installation was manually aborted
|
||||
// and if so, immediately close install screen
|
||||
if (runner.getStep<InstallStep>().state == StepState.Skipped) {
|
||||
mutableState.value = PatchingScreenState.CloseScreen
|
||||
|
||||
Error("Installation was aborted or cancelled")
|
||||
.apply { stackTrace = emptyArray() }
|
||||
}
|
||||
// At this point, the installation has successfully completed
|
||||
else {
|
||||
mutableState.value = PatchingScreenState.Success
|
||||
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(BuildConfig.TAG, "Failed to perform installation process", error)
|
||||
mutableState.value = PatchingScreenState.Failed(installId = installId!!)
|
||||
|
||||
if (error is InsufficientStorageException) {
|
||||
mainThread { application.showToast(R.string.installer_insufficient_storage) }
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
installLogs.storeInstallData(
|
||||
id = installId!!,
|
||||
installDate = startTime!!,
|
||||
installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds,
|
||||
options = options,
|
||||
log = runner.getLog(),
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Global state to avoid showing the warning more than once per launch
|
||||
private var alreadyShownNetworkWarning = false
|
||||
|
||||
/**
|
||||
* Random fun facts to show on the installation screen.
|
||||
*/
|
||||
private val FUN_FACTS = arrayOf(
|
||||
R.string.fun_fact_1,
|
||||
R.string.fun_fact_2,
|
||||
R.string.fun_fact_3,
|
||||
R.string.fun_fact_4,
|
||||
R.string.fun_fact_5,
|
||||
R.string.fun_fact_6,
|
||||
R.string.fun_fact_7,
|
||||
R.string.fun_fact_8,
|
||||
R.string.fun_fact_9,
|
||||
R.string.fun_fact_10,
|
||||
R.string.fun_fact_11,
|
||||
R.string.fun_fact_12,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching
|
||||
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState.CloseScreen
|
||||
|
||||
sealed interface PatchingScreenState {
|
||||
data object Working : PatchingScreenState
|
||||
data object Success : PatchingScreenState
|
||||
data class Failed(val installId: String) : PatchingScreenState
|
||||
data object CloseScreen : PatchingScreenState
|
||||
}
|
||||
|
||||
val PatchingScreenState.isProgressChange: Boolean
|
||||
inline get() = this != CloseScreen
|
||||
|
||||
val PatchingScreenState.isFinished: Boolean
|
||||
inline get() = isProgressChange || this == CloseScreen
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState
|
||||
import com.meowarex.rlmobile.ui.screens.patching.VERTICAL_PADDING
|
||||
|
||||
@Composable
|
||||
fun FunFact(
|
||||
text: String,
|
||||
state: PatchingScreenState,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state !is PatchingScreenState.Failed,
|
||||
enter = fadeIn() + slideInVertically { it * 2 },
|
||||
exit = fadeOut() + slideOutVertically { it * 2 },
|
||||
label = "fun fact visibility"
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = text,
|
||||
label = "fun fact change",
|
||||
transitionSpec = {
|
||||
val inSpec = fadeIn(tween(220, delayMillis = 90)) + slideInHorizontally { it * -2 }
|
||||
val outSpec = fadeOut(tween(90)) + slideOutHorizontally { it * 2 }
|
||||
inSpec togetherWith outSpec
|
||||
}
|
||||
) { text ->
|
||||
Text(
|
||||
text = stringResource(R.string.fun_fact_prefix, text),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.padding(top = VERTICAL_PADDING, bottom = 25.dp, start = VERTICAL_PADDING, end = VERTICAL_PADDING)
|
||||
.fillMaxWidth()
|
||||
.alpha(.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun PatchingAppBar(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.installer)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_back),
|
||||
contentDescription = stringResource(R.string.navigation_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun StepGroupCard(
|
||||
name: String,
|
||||
subSteps: ImmutableList<Step>,
|
||||
isExpanded: Boolean,
|
||||
onExpand: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val groupState by remember(subSteps) {
|
||||
derivedStateOf {
|
||||
when {
|
||||
// If all steps are pending then show pending
|
||||
subSteps.all { it.state == StepState.Pending } -> StepState.Pending
|
||||
// If any step has finished with an error then default to error
|
||||
subSteps.any { it.state == StepState.Error } -> StepState.Error
|
||||
// If all steps have finished as Skipped/Success then show success
|
||||
subSteps.all { it.state.isFinished } -> StepState.Success
|
||||
|
||||
else -> StepState.Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val totalSeconds = remember(groupState.isFinished) {
|
||||
if (!groupState.isFinished) {
|
||||
0f
|
||||
} else {
|
||||
subSteps
|
||||
.sumOf { step -> step.getDuration() }
|
||||
.div(1000f)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(groupState) {
|
||||
if (groupState == StepState.Running)
|
||||
onExpand()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier
|
||||
.clickable(true, onClick = onExpand)
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
) {
|
||||
StepStateIcon(
|
||||
state = groupState,
|
||||
size = 24.dp,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier
|
||||
.basicMarquee()
|
||||
.weight(0.05f),
|
||||
)
|
||||
|
||||
TimeElapsed(
|
||||
enabled = groupState.isFinished,
|
||||
seconds = totalSeconds,
|
||||
)
|
||||
|
||||
if (isExpanded) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_up_small),
|
||||
contentDescription = stringResource(R.string.action_collapse)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_down_small),
|
||||
contentDescription = stringResource(R.string.action_expand)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background.copy(0.6f))
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp)
|
||||
.padding(start = 4.dp)
|
||||
) {
|
||||
for (step in subSteps) key(step) {
|
||||
StepItem(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.patcher.steps.base.Step
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
|
||||
@Composable
|
||||
fun StepItem(
|
||||
step: Step,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
StepStateIcon(
|
||||
size = 18.dp,
|
||||
state = step.state,
|
||||
stepProgress = step.progress,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(step.localizedName),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
.thenIf(step.state == StepState.Running) { basicMarquee() },
|
||||
)
|
||||
|
||||
TimeElapsed(
|
||||
enabled = step.state != StepState.Pending,
|
||||
seconds = step.collectDurationAsState().value,
|
||||
)
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.patcher.steps.base.StepState
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun StepStateIcon(
|
||||
state: StepState,
|
||||
size: Dp,
|
||||
stepProgress: Float = -1f,
|
||||
) {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = stepProgress,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessVeryLow),
|
||||
label = "Progress",
|
||||
)
|
||||
|
||||
Crossfade(targetState = state, label = "State CrossFade") { animatedState ->
|
||||
when (animatedState) {
|
||||
StepState.Pending -> Icon(
|
||||
painter = painterResource(R.drawable.ic_circle),
|
||||
contentDescription = stringResource(R.string.status_queued),
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(.2f),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Running -> {
|
||||
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
||||
|
||||
if (stepProgress > .05f) {
|
||||
CircularProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
strokeWidth = strokeWidth,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.semantics { contentDescription = "${(stepProgress * 100).roundToInt()}%" },
|
||||
)
|
||||
} else {
|
||||
val description = stringResource(R.string.status_ongoing)
|
||||
|
||||
// Infinite spinner
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = strokeWidth,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.semantics { contentDescription = description },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StepState.Success -> Icon(
|
||||
painter = painterResource(R.drawable.ic_check_circle),
|
||||
contentDescription = stringResource(R.string.status_success),
|
||||
tint = Color(0xFF59B463),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Error -> Icon(
|
||||
painter = painterResource(R.drawable.ic_canceled),
|
||||
contentDescription = stringResource(R.string.status_failed),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
|
||||
StepState.Skipped -> Icon(
|
||||
painter = painterResource(R.drawable.ic_check_circle),
|
||||
contentDescription = stringResource(R.string.status_skipped),
|
||||
tint = Color(0xFFAEAEAE),
|
||||
modifier = Modifier.size(size)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.util.thenIf
|
||||
|
||||
@Composable
|
||||
fun TextBanner(
|
||||
text: String,
|
||||
icon: Painter,
|
||||
iconColor: Color,
|
||||
outlineColor: Color?,
|
||||
containerColor: Color,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.thenIf(outlineColor) { color ->
|
||||
border(
|
||||
width = 2.dp,
|
||||
color = color,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(containerColor)
|
||||
.thenIf(onClick) { clickable(onClick = it) }
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
tint = iconColor,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patching.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun TimeElapsed(
|
||||
seconds: Float,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = enabled,
|
||||
enter = fadeIn(),
|
||||
exit = ExitTransition.None,
|
||||
label = "TimeElapsed Visibility"
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.time_elapsed_seconds, seconds),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Immutable
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class PatchOptions(
|
||||
/**
|
||||
* The app name that's user-facing in launchers.
|
||||
*/
|
||||
val appName: String,
|
||||
|
||||
/**
|
||||
* Changes the installation package name.
|
||||
*/
|
||||
val packageName: String,
|
||||
|
||||
/**
|
||||
* Adding the debuggable APK flag.
|
||||
*/
|
||||
val debuggable: Boolean,
|
||||
|
||||
/**
|
||||
* A custom build of injector that was used rather than the latest.
|
||||
*/
|
||||
val customInjector: PatchComponent? = null,
|
||||
|
||||
/**
|
||||
* A custom smali patches bundle that was used rather than the latest.
|
||||
*/
|
||||
val customPatches: PatchComponent? = null,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
val Default = PatchOptions(
|
||||
appName = "TIDAL",
|
||||
packageName = "com.tidal.music",
|
||||
debuggable = false,
|
||||
customInjector = null,
|
||||
customPatches = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import androidx.compose.runtime.*
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.ComponentOptionsScreen
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.util.pushForResult
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PatchOptionsModel(
|
||||
prefilledOptions: PatchOptions,
|
||||
private val context: Context,
|
||||
private val prefs: PreferencesManager,
|
||||
) : ScreenModel {
|
||||
// ---------- Package name state ----------
|
||||
var packageName by mutableStateOf(prefilledOptions.packageName)
|
||||
private set
|
||||
|
||||
var packageNameState by mutableStateOf(PackageNameState.Ok)
|
||||
private set
|
||||
|
||||
fun changePackageName(newPackageName: String) {
|
||||
packageName = newPackageName
|
||||
fetchPkgNameStateDebounced()
|
||||
}
|
||||
|
||||
// ---------- App name state ----------
|
||||
var appName by mutableStateOf(prefilledOptions.appName)
|
||||
private set
|
||||
|
||||
var appNameIsError by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun changeAppName(newAppName: String) {
|
||||
appName = newAppName
|
||||
appNameIsError = newAppName.length !in (1..150)
|
||||
}
|
||||
|
||||
// ---------- Debuggable state ----------
|
||||
var debuggable by mutableStateOf(prefilledOptions.debuggable)
|
||||
private set
|
||||
|
||||
fun changeDebuggable(value: Boolean) {
|
||||
debuggable = value
|
||||
}
|
||||
|
||||
// ---------- Custom components state ----------
|
||||
var customInjector by mutableStateOf<PatchComponent?>(null)
|
||||
private set
|
||||
var customPatches by mutableStateOf<PatchComponent?>(null)
|
||||
private set
|
||||
|
||||
fun selectCustomInjector(navigator: Navigator) = screenModelScope.launch {
|
||||
customInjector = navigator.pushForResult(
|
||||
ComponentOptionsScreen(
|
||||
default = customInjector,
|
||||
componentType = PatchComponent.Type.Injector,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun selectCustomPatches(navigator: Navigator) = screenModelScope.launch {
|
||||
customPatches = navigator.pushForResult(
|
||||
ComponentOptionsScreen(
|
||||
default = customPatches,
|
||||
componentType = PatchComponent.Type.Patches,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Config generation ----------
|
||||
val isConfigValid by derivedStateOf {
|
||||
val invalidChecks = arrayOf(
|
||||
packageNameState == PackageNameState.Invalid,
|
||||
appNameIsError,
|
||||
)
|
||||
|
||||
invalidChecks.none { it }
|
||||
}
|
||||
|
||||
fun generateConfig(): PatchOptions {
|
||||
if (!isConfigValid) error("invalid config state")
|
||||
|
||||
return PatchOptions(
|
||||
appName = appName,
|
||||
packageName = packageName,
|
||||
debuggable = debuggable,
|
||||
customInjector = customInjector,
|
||||
customPatches = customPatches,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Other ----------
|
||||
val isDevMode: Boolean
|
||||
get() = prefs.devMode
|
||||
|
||||
// A throttled variant of fetchPkgNameState()
|
||||
private val fetchPkgNameStateDebounced: () -> Unit =
|
||||
screenModelScope.debounce(100L, function = ::fetchPkgNameState)
|
||||
|
||||
private suspend fun fetchPkgNameState() {
|
||||
val state = if (packageName.length !in (3..150) || !PACKAGE_REGEX.matches(this.packageName)) {
|
||||
PackageNameState.Invalid
|
||||
} else {
|
||||
try {
|
||||
context.packageManager.getPackageInfo(packageName, 0)
|
||||
PackageNameState.Taken
|
||||
} catch (_: NameNotFoundException) {
|
||||
PackageNameState.Ok
|
||||
}
|
||||
}
|
||||
|
||||
mainThread { packageNameState = state }
|
||||
}
|
||||
|
||||
init {
|
||||
screenModelScope.launchBlock { fetchPkgNameState() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val PACKAGE_REGEX = """^[a-z]\w*(\.[a-z]\w*)+$"""
|
||||
.toRegex(RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
|
||||
enum class PackageNameState {
|
||||
Ok,
|
||||
Invalid,
|
||||
Taken,
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
|
||||
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PackageNameStateLabel
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.PatchOptionsAppBar
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.components.options.*
|
||||
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Parcelize
|
||||
class PatchOptionsScreen(
|
||||
private val prefilledOptions: PatchOptions? = null,
|
||||
) : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "PatchOptions"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = koinScreenModel<PatchOptionsModel> { parametersOf(prefilledOptions ?: PatchOptions.Default) }
|
||||
|
||||
PatchOptionsScreenContent(
|
||||
isUpdate = prefilledOptions != null,
|
||||
isDevMode = model.isDevMode,
|
||||
|
||||
debuggable = model.debuggable,
|
||||
setDebuggable = model::changeDebuggable,
|
||||
|
||||
appName = model.appName,
|
||||
appNameIsError = model.appNameIsError,
|
||||
setAppName = model::changeAppName,
|
||||
|
||||
packageName = model.packageName,
|
||||
packageNameState = model.packageNameState,
|
||||
setPackageName = model::changePackageName,
|
||||
|
||||
customInjector = model.customInjector,
|
||||
customPatches = model.customPatches,
|
||||
onSelectCustomInjector = { model.selectCustomInjector(navigator) },
|
||||
onSelectCustomPatches = { model.selectCustomPatches(navigator) },
|
||||
|
||||
isConfigValid = model.isConfigValid,
|
||||
onInstall = {
|
||||
navigator.push(PatchingScreen(model.generateConfig()))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PatchOptionsScreenContent(
|
||||
isUpdate: Boolean,
|
||||
isDevMode: Boolean,
|
||||
|
||||
debuggable: Boolean,
|
||||
setDebuggable: (Boolean) -> Unit,
|
||||
|
||||
appName: String,
|
||||
appNameIsError: Boolean,
|
||||
setAppName: (String) -> Unit,
|
||||
|
||||
packageName: String,
|
||||
packageNameState: PackageNameState,
|
||||
setPackageName: (String) -> Unit,
|
||||
|
||||
customInjector: PatchComponent?,
|
||||
onSelectCustomInjector: () -> Unit,
|
||||
customPatches: PatchComponent?,
|
||||
onSelectCustomPatches: () -> Unit,
|
||||
|
||||
isConfigValid: Boolean,
|
||||
onInstall: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { PatchOptionsAppBar(isUpdate = isUpdate) },
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedByLastAtBottom(20.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patchopts_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
TextDivider(text = stringResource(R.string.patchopts_divider_basic))
|
||||
|
||||
val appNameIsDefault by remember {
|
||||
derivedStateOf {
|
||||
appName == PatchOptions.Default.appName
|
||||
}
|
||||
}
|
||||
TextPatchOption(
|
||||
name = stringResource(R.string.patchopts_appname_title),
|
||||
description = stringResource(R.string.patchopts_appname_desc),
|
||||
value = appName,
|
||||
valueIsError = appNameIsError,
|
||||
valueIsDefault = appNameIsDefault,
|
||||
onValueChange = setAppName,
|
||||
onValueReset = { setAppName(PatchOptions.Default.appName) },
|
||||
)
|
||||
|
||||
if (!isUpdate) {
|
||||
val packageNameIsDefault by remember {
|
||||
derivedStateOf {
|
||||
packageName == PatchOptions.Default.packageName
|
||||
}
|
||||
}
|
||||
TextPatchOption(
|
||||
name = stringResource(R.string.patchopts_pkgname_title),
|
||||
description = stringResource(R.string.patchopts_pkgname_desc),
|
||||
value = packageName,
|
||||
valueIsError = packageNameState == PackageNameState.Invalid,
|
||||
valueIsDefault = packageNameIsDefault,
|
||||
onValueChange = setPackageName,
|
||||
onValueReset = { setPackageName(PatchOptions.Default.packageName) },
|
||||
) {
|
||||
PackageNameStateLabel(
|
||||
state = packageNameState,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
TextDivider(
|
||||
text = stringResource(R.string.patchopts_divider_advanced),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
|
||||
SwitchPatchOption(
|
||||
icon = painterResource(R.drawable.ic_bug),
|
||||
name = stringResource(R.string.patchopts_debuggable_title),
|
||||
description = stringResource(R.string.patchopts_debuggable_desc),
|
||||
value = debuggable,
|
||||
onValueChange = setDebuggable,
|
||||
)
|
||||
|
||||
IconPatchOption(
|
||||
icon = painterResource(R.drawable.ic_extension),
|
||||
name = stringResource(R.string.patchopts_custom_injector_title),
|
||||
description = stringResource(R.string.patchopts_custom_injector_desc),
|
||||
modifier = Modifier.clickable(onClick = onSelectCustomInjector),
|
||||
) {
|
||||
FilledTonalButton(onClick = onSelectCustomInjector) {
|
||||
Text(
|
||||
text = customInjector?.version?.toString()
|
||||
?: stringResource(R.string.componentopts_selected_none)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconPatchOption(
|
||||
icon = painterResource(R.drawable.ic_extension),
|
||||
name = stringResource(R.string.patchopts_custom_patches_title),
|
||||
description = stringResource(R.string.patchopts_custom_patches_desc),
|
||||
modifier = Modifier.clickable(onClick = onSelectCustomPatches),
|
||||
) {
|
||||
FilledTonalButton(onClick = onSelectCustomPatches) {
|
||||
Text(
|
||||
text = customPatches?.version?.toString()
|
||||
?: stringResource(R.string.componentopts_selected_none)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
FilledTonalButton(
|
||||
enabled = isConfigValid,
|
||||
onClick = onInstall,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 10.dp)
|
||||
.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.action_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.patchopts.PackageNameState
|
||||
|
||||
@Composable
|
||||
fun PackageNameStateLabel(
|
||||
state: PackageNameState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = state,
|
||||
label = "PackageNameStateLabel CrossFade"
|
||||
) { animatedState ->
|
||||
val (label, icon, tint) = when (animatedState) {
|
||||
PackageNameState.Invalid -> Triple(
|
||||
R.string.patchopts_pkgname_invalid,
|
||||
R.drawable.ic_canceled,
|
||||
MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
PackageNameState.Taken -> Triple(
|
||||
R.string.patchopts_pkgname_taken,
|
||||
R.drawable.ic_warning,
|
||||
Color(0xFFFFCC00),
|
||||
)
|
||||
|
||||
PackageNameState.Ok -> Triple(
|
||||
R.string.patchopts_pkgname_ok,
|
||||
R.drawable.ic_check_circle,
|
||||
Color(0xFF59B463),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(label),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alpha(.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components
|
||||
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.BackButton
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun PatchOptionsAppBar(
|
||||
isUpdate: Boolean = false,
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = { BackButton() },
|
||||
title = { Text(stringResource(if (!isUpdate) R.string.action_add_install else R.string.action_update_install)) },
|
||||
actions = {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_settings),
|
||||
contentDescription = stringResource(R.string.navigation_settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.unit.dp
|
||||
|
||||
@Composable
|
||||
fun IconPatchOption(
|
||||
icon: Painter,
|
||||
name: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.alpha(.7f)
|
||||
)
|
||||
}
|
||||
|
||||
content()
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SwitchPatchOption(
|
||||
icon: Painter,
|
||||
name: String,
|
||||
description: String,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val interactionSource = remember(::MutableInteractionSource)
|
||||
val onClick = remember(value) { { onValueChange(!value) } }
|
||||
|
||||
IconPatchOption(
|
||||
icon = icon,
|
||||
name = name,
|
||||
description = description,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
role = Role.Switch,
|
||||
),
|
||||
) {
|
||||
Switch(
|
||||
checked = value,
|
||||
enabled = enabled,
|
||||
onCheckedChange = onValueChange,
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.meowarex.rlmobile.ui.screens.patchopts.components.options
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.ui.components.Label
|
||||
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
|
||||
|
||||
@Composable
|
||||
fun TextPatchOption(
|
||||
name: String,
|
||||
description: String,
|
||||
value: String,
|
||||
valueIsError: Boolean,
|
||||
valueIsDefault: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
onValueReset: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
extra: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
Label(
|
||||
name = name,
|
||||
description = description,
|
||||
modifier = modifier,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
isError = valueIsError,
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
errorContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
),
|
||||
trailingIcon = {
|
||||
ResetToDefaultButton(
|
||||
enabled = !valueIsDefault,
|
||||
onClick = onValueReset,
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
|
||||
extra?.invoke(this)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.meowarex.rlmobile.ui.screens.permissions
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.di.ActivityProvider
|
||||
import com.meowarex.rlmobile.manager.InstallerSetting
|
||||
import com.meowarex.rlmobile.manager.PreferencesManager
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import java.util.UUID
|
||||
|
||||
class PermissionsModel(
|
||||
private val application: Application,
|
||||
private val activities: ActivityProvider,
|
||||
private val preferences: PreferencesManager,
|
||||
) : ViewModel() {
|
||||
private var timesRequestedNotificationsPerms = 0
|
||||
|
||||
var showInstallersDialog by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val installer: InstallerSetting
|
||||
get() = preferences.installer
|
||||
|
||||
var storagePermsGranted by mutableStateOf(false)
|
||||
private set
|
||||
var unknownSourcesPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 26)
|
||||
private set
|
||||
var notificationsPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 33)
|
||||
private set
|
||||
var batteryPermsGranted by mutableStateOf(Build.VERSION.SDK_INT < 24)
|
||||
private set
|
||||
|
||||
val requiredPermsGranted by derivedStateOf {
|
||||
// Unknown Sources permission is only required when the installer is PM
|
||||
if (preferences.installer == InstallerSetting.PackageInstaller && !unknownSourcesPermsGranted)
|
||||
return@derivedStateOf false
|
||||
|
||||
storagePermsGranted
|
||||
}
|
||||
val allPermsGranted by derivedStateOf {
|
||||
requiredPermsGranted && notificationsPermsGranted && batteryPermsGranted
|
||||
}
|
||||
|
||||
fun showInstallersDialog() {
|
||||
showInstallersDialog = true
|
||||
}
|
||||
|
||||
fun hideInstallersDialog() {
|
||||
showInstallersDialog = false
|
||||
}
|
||||
|
||||
fun setInstaller(installer: InstallerSetting) {
|
||||
preferences.installer = installer
|
||||
}
|
||||
|
||||
fun requestUnknownSourcesPerms() {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
||||
.setData("package:${BuildConfig.APPLICATION_ID}".toUri())
|
||||
.let(activities.get<Activity>()::startActivity)
|
||||
}
|
||||
|
||||
fun requestStoragePerms() = permissionRequestLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun requestManageStoragePerms() {
|
||||
Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
.setData("package:${BuildConfig.APPLICATION_ID}".toUri())
|
||||
.let(activities.get<Activity>()::startActivity)
|
||||
}
|
||||
|
||||
fun requestNotificationsPerms() {
|
||||
if (Build.VERSION.SDK_INT < 33) return
|
||||
|
||||
// If the user denies the permission twice (not dismiss), then the dialog will no longer show,
|
||||
// and the user will have to manually enable it from system settings.
|
||||
if (++timesRequestedNotificationsPerms <= 2) {
|
||||
permissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, BuildConfig.APPLICATION_ID)
|
||||
.let(activities.get<Activity>()::startActivity)
|
||||
}
|
||||
}
|
||||
|
||||
fun grantBatteryPerms() {
|
||||
if (Build.VERSION.SDK_INT < 23) return
|
||||
|
||||
activities.get<Activity>().requestNoBatteryOptimizations()
|
||||
}
|
||||
|
||||
fun refresh() = viewModelScope.launchBlock {
|
||||
storagePermsGranted = if (Build.VERSION.SDK_INT >= 30) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
application.selfHasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
unknownSourcesPermsGranted = Build.VERSION.SDK_INT < 26 || application.packageManager.canRequestPackageInstalls()
|
||||
notificationsPermsGranted = Build.VERSION.SDK_INT < 33 || application.selfHasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
batteryPermsGranted = Build.VERSION.SDK_INT < 24 || application.isIgnoringBatteryOptimizations()
|
||||
}
|
||||
|
||||
init {
|
||||
refresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
permissionRequestLauncher.unregister()
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for requesting permissions that launch a popup to the user.
|
||||
* Refreshes the permissions state once returned to the app.
|
||||
*/
|
||||
private val permissionRequestLauncher = run {
|
||||
val activity = activities.get<ComponentActivity>()
|
||||
|
||||
activity.activityResultRegistry.register(
|
||||
key = UUID.randomUUID().toString(),
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
callback = { refresh() },
|
||||
)
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package com.meowarex.rlmobile.ui.screens.permissions
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.core.annotation.ExperimentalVoyagerApi
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.core.stack.StackEvent
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.transitions.ScreenTransition
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.InstallerSetting
|
||||
import com.meowarex.rlmobile.ui.components.TextDivider
|
||||
import com.meowarex.rlmobile.ui.components.settings.SettingsItem
|
||||
import com.meowarex.rlmobile.ui.screens.home.HomeScreen
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionButton
|
||||
import com.meowarex.rlmobile.ui.screens.permissions.components.PermissionsAppBar
|
||||
import com.meowarex.rlmobile.ui.screens.settings.components.InstallersDialog
|
||||
import com.meowarex.rlmobile.ui.util.paddings.*
|
||||
import com.meowarex.rlmobile.ui.util.spacedByLastAtBottom
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.compose.viewmodel.koinActivityViewModel
|
||||
|
||||
@Parcelize
|
||||
@OptIn(ExperimentalVoyagerApi::class)
|
||||
class PermissionsScreen : Screen, ScreenTransition, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "Permissions"
|
||||
|
||||
override fun enter(lastEvent: StackEvent): EnterTransition? = EnterTransition.None
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val model = koinActivityViewModel<PermissionsModel>()
|
||||
|
||||
// Go back (ex: HomeScreen) when all permissions have been granted
|
||||
LaunchedEffect(model.allPermsGranted) {
|
||||
if (model.allPermsGranted)
|
||||
navigator.pop()
|
||||
}
|
||||
|
||||
if (model.showInstallersDialog) {
|
||||
InstallersDialog(
|
||||
currentInstaller = model.installer,
|
||||
onDismiss = model::hideInstallersDialog,
|
||||
onConfirm = model::setInstaller,
|
||||
)
|
||||
}
|
||||
|
||||
PermissionsScreenContent(
|
||||
installer = model.installer,
|
||||
openInstallersDialog = model::showInstallersDialog,
|
||||
storagePermsGranted = model.storagePermsGranted,
|
||||
onGrantStoragePerms = if (Build.VERSION.SDK_INT >= 30) {
|
||||
model::requestManageStoragePerms
|
||||
} else {
|
||||
model::requestStoragePerms
|
||||
},
|
||||
unknownSourcesPermsGranted = model.unknownSourcesPermsGranted,
|
||||
onGrantUnknownSourcesPerms = model::requestUnknownSourcesPerms,
|
||||
notificationsPermsGranted = model.notificationsPermsGranted,
|
||||
onGrantNotificationsPerms = model::requestNotificationsPerms,
|
||||
batteryPermsGranted = model.batteryPermsGranted,
|
||||
onGrantBatteryPerms = model::grantBatteryPerms,
|
||||
canContinue = model.requiredPermsGranted,
|
||||
onContinue = { navigator.replace(HomeScreen()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionsScreenContent(
|
||||
installer: InstallerSetting,
|
||||
openInstallersDialog: () -> Unit,
|
||||
storagePermsGranted: Boolean,
|
||||
onGrantStoragePerms: () -> Unit,
|
||||
unknownSourcesPermsGranted: Boolean,
|
||||
onGrantUnknownSourcesPerms: () -> Unit,
|
||||
notificationsPermsGranted: Boolean,
|
||||
onGrantNotificationsPerms: () -> Unit,
|
||||
batteryPermsGranted: Boolean,
|
||||
onGrantBatteryPerms: () -> Unit,
|
||||
canContinue: Boolean,
|
||||
onContinue: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { PermissionsAppBar() },
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedByLastAtBottom(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
contentPadding = padding
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
|
||||
.add(PaddingValues(bottom = 12.dp, top = 24.dp)),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding.exclude(PaddingValuesSides.Bottom))
|
||||
) {
|
||||
item(key = "DIVIDER_OPTIONS", contentType = "DIVIDER") {
|
||||
TextDivider(
|
||||
text = stringResource(R.string.permissions_header_options),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "INSTALLER") {
|
||||
SettingsItem(
|
||||
text = { Text(stringResource(R.string.setting_installer)) },
|
||||
secondaryText = { Text(stringResource(R.string.setting_installer_desc)) },
|
||||
icon = { Icon(painterResource(R.drawable.ic_apk_install), null) },
|
||||
modifier = Modifier.clickable(onClick = openInstallersDialog),
|
||||
) {
|
||||
FilledTonalButton(onClick = openInstallersDialog) {
|
||||
Icon(
|
||||
painter = installer.icon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.padding(end = 6.dp),
|
||||
)
|
||||
Text(installer.title())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "DIVIDER_PERMS", contentType = "DIVIDER") {
|
||||
TextDivider(
|
||||
text = stringResource(R.string.permissions_header_permissions),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (installer == InstallerSetting.PackageInstaller) {
|
||||
item(key = "PERMS_UNKNOWN_SOURCES", contentType = "PERMISSION_BUTTON") {
|
||||
PermissionButton(
|
||||
name = stringResource(R.string.permissions_install_title),
|
||||
description = stringResource(R.string.permissions_install_desc),
|
||||
granted = unknownSourcesPermsGranted,
|
||||
required = true,
|
||||
icon = painterResource(R.drawable.ic_alt_route),
|
||||
onClick = onGrantUnknownSourcesPerms,
|
||||
modifier = Modifier.animateItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "PERMS_STORAGE", contentType = "PERMISSION_BUTTON") {
|
||||
PermissionButton(
|
||||
name = stringResource(R.string.permissions_storage_title),
|
||||
description = stringResource(R.string.permissions_storage_desc),
|
||||
granted = storagePermsGranted,
|
||||
required = true,
|
||||
icon = painterResource(R.drawable.ic_save),
|
||||
onClick = onGrantStoragePerms,
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "PERMS_NOTIFICATIONS", contentType = "PERMISSION_BUTTON") {
|
||||
PermissionButton(
|
||||
name = stringResource(R.string.permissions_notifs_title),
|
||||
description = stringResource(R.string.permissions_notifs_desc),
|
||||
granted = notificationsPermsGranted,
|
||||
required = false,
|
||||
icon = painterResource(R.drawable.ic_bell),
|
||||
onClick = onGrantNotificationsPerms,
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "PERMS_BATTERY", contentType = "PERMISSION_BUTTON") {
|
||||
PermissionButton(
|
||||
name = stringResource(R.string.permissions_battery_title),
|
||||
description = stringResource(R.string.permissions_battery_desc),
|
||||
granted = batteryPermsGranted,
|
||||
required = false,
|
||||
icon = painterResource(R.drawable.ic_battery_settings),
|
||||
onClick = onGrantBatteryPerms,
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "LEGEND") {
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_legend, "*"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 32.dp, vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "CONTINUE") {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, end = 32.dp),
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = onContinue,
|
||||
enabled = canContinue,
|
||||
) {
|
||||
Text(stringResource(R.string.action_continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.meowarex.rlmobile.ui.screens.permissions.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun PermissionButton(
|
||||
name: String,
|
||||
description: String,
|
||||
granted: Boolean,
|
||||
required: Boolean,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = modifier
|
||||
.heightIn(min = 64.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Companion.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
|
||||
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
|
||||
Text(name)
|
||||
|
||||
if (required) {
|
||||
Text(
|
||||
text = "*",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Companion.CenterVertically,
|
||||
) {
|
||||
ProvideTextStyle(
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = !granted,
|
||||
) {
|
||||
Text(stringResource(if (granted) R.string.permissions_granted else R.string.permissions_grant))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.meowarex.rlmobile.ui.screens.permissions.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.settings.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun PermissionsAppBar() {
|
||||
LargeTopAppBar(
|
||||
title = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_title),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.permissions_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(.6f),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val navigator = LocalNavigator.current
|
||||
|
||||
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_settings),
|
||||
contentDescription = stringResource(R.string.navigation_settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.manager.PathManager
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginManifest
|
||||
import com.meowarex.rlmobile.ui.util.emptyImmutableList
|
||||
import com.meowarex.rlmobile.ui.util.toUnsafeImmutable
|
||||
import com.meowarex.rlmobile.util.*
|
||||
import com.github.diamondminer88.zip.ZipReader
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.plus
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import kotlin.time.Duration
|
||||
|
||||
class PluginsModel(
|
||||
private val context: Application,
|
||||
private val paths: PathManager,
|
||||
private val json: Json,
|
||||
) : ScreenModel {
|
||||
private val plugins = MutableStateFlow<ImmutableList<PluginItem>>(emptyImmutableList())
|
||||
|
||||
var error by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var showChangelogDialog by mutableStateOf<PluginItem?>(null)
|
||||
private set
|
||||
|
||||
var showUninstallDialog by mutableStateOf<PluginItem?>(null)
|
||||
private set
|
||||
|
||||
val searchText: StateFlow<String>
|
||||
field = MutableStateFlow("")
|
||||
|
||||
var pluginsSafeMode = MutableStateFlow(false)
|
||||
private set
|
||||
|
||||
val filteredPlugins: StateFlow<ImmutableList<PluginItem>> = searchText
|
||||
.combine(plugins) { searchText, plugins ->
|
||||
if (searchText.isBlank()) {
|
||||
plugins
|
||||
} else {
|
||||
plugins.filter { plugin ->
|
||||
plugin.manifest.name.contains(searchText, ignoreCase = true)
|
||||
|| plugin.manifest.description.contains(searchText, ignoreCase = true)
|
||||
|| plugin.manifest.authors.any { (name) -> name.contains(searchText, ignoreCase = true) }
|
||||
}.toUnsafeImmutable()
|
||||
}
|
||||
}.stateIn(
|
||||
scope = screenModelScope + Dispatchers.Default,
|
||||
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO),
|
||||
initialValue = plugins.value,
|
||||
)
|
||||
|
||||
// ---- State setters ---- //
|
||||
|
||||
fun setSearchText(search: String) {
|
||||
searchText.value = search
|
||||
}
|
||||
|
||||
fun showChangelogDialog(plugin: PluginItem) {
|
||||
showChangelogDialog = plugin
|
||||
}
|
||||
|
||||
fun hideChangelogDialog() {
|
||||
showChangelogDialog = null
|
||||
}
|
||||
|
||||
fun showUninstallDialog(plugin: PluginItem) {
|
||||
showUninstallDialog = plugin
|
||||
}
|
||||
|
||||
fun hideUninstallDialog() {
|
||||
showUninstallDialog = null
|
||||
}
|
||||
|
||||
// ---- IO state setters ---- //
|
||||
|
||||
fun uninstallPlugin(plugin: PluginItem) = screenModelScope.launchIO {
|
||||
if (!plugins.value.any { it.path == plugin.path }) {
|
||||
mainThread { hideUninstallDialog() }
|
||||
return@launchIO
|
||||
}
|
||||
|
||||
val deleteSuccess = try {
|
||||
File(plugin.path).delete()
|
||||
} catch (t: Throwable) {
|
||||
Log.e(BuildConfig.TAG, "Failed to delete plugin", t)
|
||||
false
|
||||
}
|
||||
|
||||
if (!deleteSuccess) {
|
||||
mainThread {
|
||||
hideUninstallDialog()
|
||||
context.showToast(R.string.plugins_error)
|
||||
}
|
||||
return@launchIO
|
||||
}
|
||||
|
||||
plugins.update { (it - plugin).toUnsafeImmutable() }
|
||||
mainThread { hideUninstallDialog() }
|
||||
}
|
||||
|
||||
fun setPluginEnabled(pluginName: String, enabled: Boolean) = screenModelScope.launchIO {
|
||||
try {
|
||||
editTidalSettings {
|
||||
put(JsonPrimitive("AC_PM_$pluginName"), JsonPrimitive(enabled))
|
||||
}
|
||||
mainThread {
|
||||
plugins.value.forEach {
|
||||
if (it.manifest.name == pluginName)
|
||||
it.enabled = enabled
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to toggle plugin", e)
|
||||
mainThread { context.showToast(R.string.status_failed) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setSafeMode(safeMode: Boolean) = screenModelScope.launchIO {
|
||||
try {
|
||||
editTidalSettings {
|
||||
put(JsonPrimitive("RL_safe_mode_enabled"), JsonPrimitive(safeMode))
|
||||
}
|
||||
pluginsSafeMode.value = safeMode
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to toggle plugin", e)
|
||||
mainThread { context.showToast(R.string.status_failed) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- State loading ---- //
|
||||
|
||||
// Called by screen to load initial data
|
||||
fun refreshData() = screenModelScope.launchIO {
|
||||
try {
|
||||
loadSafeMode()
|
||||
loadPlugins()
|
||||
loadPluginsEnabled()
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to load plugins state", e)
|
||||
mainThread {
|
||||
context.showToast(R.string.plugins_error)
|
||||
error = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSafeMode() = screenModelScope.launchIO {
|
||||
@Serializable
|
||||
data class SafeModeSettings(
|
||||
@SerialName("RL_safe_mode_enabled")
|
||||
val safeMode: Boolean = false,
|
||||
)
|
||||
|
||||
pluginsSafeMode.value = readTidalSettings<SafeModeSettings>()?.safeMode ?: false
|
||||
}
|
||||
|
||||
private suspend fun loadPluginsEnabled() {
|
||||
val pluginToggles = readTidalSettings<Map<JsonPrimitive, JsonElement>>()
|
||||
?.filterKeys { it.isString && it.content.startsWith("AC_PM_") }
|
||||
?.filterValues { (it as? JsonPrimitive)?.booleanOrNull == true }
|
||||
?.mapKeys { (key, _) -> key.content.substring("AC_PM_".length) }
|
||||
?.mapValues { (_, value) -> value.jsonPrimitive.boolean }
|
||||
|
||||
if (pluginToggles != null) mainThread {
|
||||
plugins.value.forEach {
|
||||
if (!pluginToggles.getOrDefault(it.manifest.name, true))
|
||||
it.enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPlugins() {
|
||||
if (!paths.pluginsDir.exists() && !paths.pluginsDir.mkdirs())
|
||||
throw IllegalStateException("Failed to create plugins directory")
|
||||
|
||||
val pluginFiles = paths.pluginsDir.listFiles { file -> file.extension == "zip" }
|
||||
?: throw IllegalStateException("Failed to read plugins directory")
|
||||
|
||||
val pluginItems = pluginFiles
|
||||
.mapNotNull {
|
||||
try {
|
||||
PluginItem(
|
||||
manifest = loadPluginManifest(it),
|
||||
path = it.absolutePath,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Failed to load plugin at ${it.absolutePath}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
.sortedBy { it.manifest.name }
|
||||
|
||||
plugins.value = pluginItems.toUnsafeImmutable()
|
||||
}
|
||||
|
||||
private fun loadPluginManifest(pluginFile: File): PluginManifest {
|
||||
return ZipReader(pluginFile).use {
|
||||
val manifest = it.openEntry("manifest.json")
|
||||
?: throw Exception("Plugin ${pluginFile.name} has no manifest")
|
||||
|
||||
try {
|
||||
json.decodeFromStream(manifest.read().inputStream())
|
||||
} catch (t: Throwable) {
|
||||
throw Exception("Failed to parse plugin manifest for ${pluginFile.name}", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Radiant Lyrics settings ---- //
|
||||
|
||||
/**
|
||||
* Reads Radiant Lyrics core's settings, applies [block] to it, and writes it back.
|
||||
*/
|
||||
private suspend fun editTidalSettings(block: (MutableMap<JsonPrimitive, JsonElement>).() -> Unit) {
|
||||
SETTINGS_MUTEX.withLock {
|
||||
val settings = try {
|
||||
if (paths.coreSettingsFile.exists()) {
|
||||
json.decodeFromStream<MutableMap<JsonPrimitive, JsonElement>>(paths.coreSettingsFile.inputStream())
|
||||
} else {
|
||||
mutableMapOf()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e)
|
||||
mutableMapOf()
|
||||
}
|
||||
|
||||
// Apply modifier block
|
||||
block(settings)
|
||||
|
||||
paths.coreSettingsFile.parentFile!!.mkdirs()
|
||||
paths.coreSettingsFile.outputStream()
|
||||
.use { out -> json.encodeToStream(settings, out) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Radiant Lyrics core's settings and parses it into a specific model.
|
||||
* This should not be used for future writes.
|
||||
*
|
||||
* @return The parsed settings model, or null if settings are missing or corrupt.
|
||||
*/
|
||||
private suspend inline fun <reified T> readTidalSettings(): T? {
|
||||
return SETTINGS_MUTEX.withLock {
|
||||
try {
|
||||
if (paths.coreSettingsFile.exists()) {
|
||||
json.decodeFromStream<T>(paths.coreSettingsFile.inputStream())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(BuildConfig.TAG, "Radiant Lyrics settings are corrupted!", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Global lock on the main Radiant Lyrics settings.
|
||||
*/
|
||||
private val SETTINGS_MUTEX = Mutex()
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import cafe.adriel.voyager.core.screen.Screen
|
||||
import cafe.adriel.voyager.koin.koinScreenModel
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.BackButton
|
||||
import com.meowarex.rlmobile.ui.components.settings.SettingsSwitch
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.components.*
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.components.dialogs.UninstallPluginDialog
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
|
||||
import com.meowarex.rlmobile.ui.util.paddings.*
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class PluginsScreen : Screen, Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val key = "Plugins"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = koinScreenModel<PluginsModel>()
|
||||
|
||||
// Refresh plugins list on activity resume or when this initially opens
|
||||
LifecycleResumeEffect(Unit) {
|
||||
model.refreshData()
|
||||
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
|
||||
model.showUninstallDialog?.let { plugin ->
|
||||
UninstallPluginDialog(
|
||||
pluginName = plugin.manifest.name,
|
||||
onConfirm = { model.uninstallPlugin(plugin) },
|
||||
onDismiss = model::hideUninstallDialog
|
||||
)
|
||||
}
|
||||
|
||||
model.showChangelogDialog?.let { plugin ->
|
||||
Changelog(
|
||||
plugin = plugin,
|
||||
onDismiss = model::hideChangelogDialog
|
||||
)
|
||||
}
|
||||
|
||||
PluginsScreenContent(
|
||||
searchText = model.searchText.collectAsState(),
|
||||
setSearchText = model::setSearchText,
|
||||
isError = model.error,
|
||||
plugins = model.filteredPlugins.collectAsState().value,
|
||||
onPluginUninstall = model::showUninstallDialog,
|
||||
onPluginChangelog = model::showChangelogDialog,
|
||||
onPluginToggle = model::setPluginEnabled,
|
||||
safeMode = model.pluginsSafeMode.collectAsState().value,
|
||||
setSafeMode = model::setSafeMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginsScreenContent(
|
||||
searchText: State<String>,
|
||||
setSearchText: (String) -> Unit,
|
||||
isError: Boolean,
|
||||
plugins: ImmutableList<PluginItem>,
|
||||
onPluginUninstall: (PluginItem) -> Unit,
|
||||
onPluginChangelog: (PluginItem) -> Unit,
|
||||
onPluginToggle: (name: String, enabled: Boolean) -> Unit,
|
||||
safeMode: Boolean,
|
||||
setSafeMode: (Boolean) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.plugins_title)) },
|
||||
navigationIcon = { BackButton() },
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
SettingsSwitch(
|
||||
label = stringResource(R.string.plugins_safe_mode_title),
|
||||
secondaryLabel = stringResource(R.string.plugins_safe_mode_desc),
|
||||
icon = { Icon(painterResource(R.drawable.ic_security), null) },
|
||||
pref = safeMode,
|
||||
onPrefChange = setSafeMode
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp)
|
||||
) {
|
||||
PluginSearch(
|
||||
currentFilter = searchText,
|
||||
onFilterChange = setSearchText,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = paddingValues
|
||||
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
|
||||
.add(PaddingValues(vertical = 12.dp)),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when {
|
||||
isError -> item(key = "ERROR") {
|
||||
PluginsError(modifier = Modifier.fillParentMaxSize())
|
||||
}
|
||||
|
||||
plugins.isNotEmpty() -> {
|
||||
items(
|
||||
items = plugins,
|
||||
contentType = { "PLUGIN" },
|
||||
key = { it.path },
|
||||
) { plugin ->
|
||||
PluginCard(
|
||||
plugin = plugin,
|
||||
onClickDelete = { onPluginUninstall(plugin) },
|
||||
onClickShowChangelog = { onPluginChangelog(plugin) },
|
||||
onSetEnabled = { onPluginToggle(plugin.manifest.name, it) },
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
else -> item("PLUGINS_NONE") {
|
||||
PluginsNone(Modifier.fillParentMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
|
||||
|
||||
private val hyperLinkPattern = Regex("\\[(.+?)]\\((.+?\\))")
|
||||
|
||||
@Suppress("RegExpRedundantEscape") // It is very much not redundant and causes a crash lol
|
||||
private val headerStylePattern = Regex("\\{(improved|added|fixed)( marginTop)?\\}")
|
||||
|
||||
@Composable
|
||||
private fun AnnotatedString.Builder.MarkdownHyperlink(content: String) {
|
||||
var idx = 0
|
||||
|
||||
with(hyperLinkPattern.toPattern().matcher(content)) {
|
||||
while (find()) {
|
||||
val start = start()
|
||||
val end = end()
|
||||
val title = group(1)!!
|
||||
val url = group(2)!!
|
||||
|
||||
append(content.substring(idx, start))
|
||||
|
||||
// @formatter:off
|
||||
pushLink(LinkAnnotation.Url(
|
||||
url,
|
||||
TextLinkStyles(SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
))
|
||||
append(title)
|
||||
pop()
|
||||
// @formatter:on
|
||||
|
||||
idx = end
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < content.length) append(content.substring(idx))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Changelog(
|
||||
plugin: PluginItem,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_history),
|
||||
contentDescription = stringResource(R.string.plugins_view_changelog, plugin.manifest.name)
|
||||
)
|
||||
},
|
||||
title = { Text(plugin.manifest.name) },
|
||||
text = {
|
||||
Column {
|
||||
plugin.manifest.changelogMedia?.let { mediaUrl ->
|
||||
AsyncImage(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 90.dp)
|
||||
.clip(RoundedCornerShape(14.dp)),
|
||||
model = mediaUrl,
|
||||
contentDescription = stringResource(R.string.plugins_changelog_media)
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
items(plugin.manifest.changelog!!.lines()) {
|
||||
var line = it.trim()
|
||||
|
||||
if (line.isNotEmpty()) {
|
||||
when (line[0]) {
|
||||
'#' -> {
|
||||
do {
|
||||
line = line.substring(1)
|
||||
} while (line.startsWith("#"))
|
||||
|
||||
Text(
|
||||
text = line.trimStart(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
'*' -> {
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 2.dp),
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
) {
|
||||
append("● ")
|
||||
}
|
||||
|
||||
MarkdownHyperlink(line.substring(1))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
line.endsWith("marginTop}") -> {
|
||||
val color = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Text(
|
||||
text = line,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = color,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
line.all { c -> c == '=' } -> {} // Tidal ignores =======
|
||||
else -> {
|
||||
Text(buildAnnotatedString {
|
||||
MarkdownHyperlink(line)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.action_close))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.screens.plugins.model.PluginItem
|
||||
|
||||
@Composable
|
||||
fun PluginCard(
|
||||
plugin: PluginItem,
|
||||
onClickDelete: () -> Unit,
|
||||
onClickShowChangelog: () -> Unit,
|
||||
onSetEnabled: (Boolean) -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ElevatedCard {
|
||||
// Header
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable { onSetEnabled(!plugin.enabled) }
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 14.dp),
|
||||
) {
|
||||
Column {
|
||||
// Name
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(plugin.manifest.name)
|
||||
}
|
||||
append(" v")
|
||||
append(plugin.manifest.version)
|
||||
}
|
||||
)
|
||||
|
||||
// Authors
|
||||
val authors = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
|
||||
for ((idx, author) in plugin.manifest.authors.withIndex()) {
|
||||
if (idx > 0) append(", ")
|
||||
|
||||
if (author.hyperlink) pushLink(
|
||||
LinkAnnotation.Url(
|
||||
url = author.socialUrl,
|
||||
styles = TextLinkStyles(
|
||||
SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
append(author.name)
|
||||
if (author.hyperlink) pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = authors,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f, true))
|
||||
|
||||
// Toggle Switch
|
||||
Switch(
|
||||
checked = plugin.enabled,
|
||||
onCheckedChange = { onSetEnabled(!plugin.enabled) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.alpha(0.3f)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Description
|
||||
Text(
|
||||
text = plugin.manifest.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 150.dp, min = 40.dp)
|
||||
.padding(bottom = 20.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
plugin.manifest.repositoryUrl?.let { repositoryUrl ->
|
||||
IconButton(
|
||||
onClick = { uriHandler.openUri(repositoryUrl) },
|
||||
modifier = Modifier.size(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
painter = painterResource(R.drawable.ic_account_github_white_24dp),
|
||||
contentDescription = stringResource(R.string.github)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (plugin.manifest.changelog != null) {
|
||||
IconButton(
|
||||
onClick = onClickShowChangelog,
|
||||
modifier = Modifier.size(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_history),
|
||||
contentDescription = stringResource(R.string.plugins_view_changelog),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f, true))
|
||||
|
||||
IconButton(
|
||||
onClick = onClickDelete,
|
||||
modifier = Modifier.size(25.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
painter = painterResource(R.drawable.ic_delete_forever),
|
||||
contentDescription = stringResource(R.string.action_uninstall),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.ui.components.ResetToDefaultButton
|
||||
|
||||
@Composable
|
||||
fun PluginSearch(
|
||||
currentFilter: State<String>,
|
||||
onFilterChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier.Companion,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentFilter.value,
|
||||
onValueChange = onFilterChange,
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
label = { Text(stringResource(R.string.action_search)) },
|
||||
trailingIcon = {
|
||||
val isFilterBlank by remember { derivedStateOf { currentFilter.value.isEmpty() } }
|
||||
|
||||
ResetToDefaultButton(
|
||||
enabled = !isFilterBlank,
|
||||
onClick = { onFilterChange("") },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrectEnabled = false,
|
||||
imeAction = ImeAction.Companion.Search
|
||||
),
|
||||
keyboardActions = KeyboardActions { focusManager.clearFocus() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun PluginsError(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_warning),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun PluginsNone(modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_extension_off),
|
||||
contentDescription = null
|
||||
)
|
||||
Text(stringResource(R.string.plugins_none_installed))
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.components.dialogs
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meowarex.rlmobile.R
|
||||
|
||||
@Composable
|
||||
fun UninstallPluginDialog(
|
||||
pluginName: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete_forever),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.plugins_delete_plugin, pluginName))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.plugins_delete_plugin_body, pluginName),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.model
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
@Stable
|
||||
data class PluginItem(
|
||||
val manifest: PluginManifest,
|
||||
val path: String,
|
||||
) {
|
||||
// Plugins are enabled by default unless disabled in Radiant Lyrics settings
|
||||
var enabled by mutableStateOf(true)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.meowarex.rlmobile.ui.screens.plugins.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.meowarex.rlmobile.util.serialization.ImmutableListSerializer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class PluginManifest(
|
||||
val name: String,
|
||||
@Serializable(with = ImmutableListSerializer::class)
|
||||
val authors: ImmutableList<Author>,
|
||||
val description: String,
|
||||
val version: String,
|
||||
val updateUrl: String?,
|
||||
val changelog: String?,
|
||||
val changelogMedia: String?,
|
||||
) {
|
||||
val repositoryUrl: String?
|
||||
get() = updateUrl?.replaceFirst(
|
||||
"https://(raw\\.githubusercontent\\.com|cdn\\.jsdelivr\\.net/gh)/([^/]+)/([^/@]+).*".toRegex(),
|
||||
"https://github.com/$2/$3"
|
||||
)
|
||||
|
||||
@Immutable
|
||||
@Serializable
|
||||
data class Author(
|
||||
val name: String,
|
||||
val id: Long,
|
||||
val hyperlink: Boolean = true,
|
||||
) {
|
||||
val socialUrl: String
|
||||
get() = "https://tidal.com/users/$id"
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.meowarex.rlmobile.ui.screens.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.core.content.FileProvider
|
||||
import cafe.adriel.voyager.core.model.ScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.meowarex.rlmobile.BuildConfig
|
||||
import com.meowarex.rlmobile.R
|
||||
import com.meowarex.rlmobile.di.ActivityProvider
|
||||
import com.meowarex.rlmobile.manager.*
|
||||
import com.meowarex.rlmobile.ui.theme.Theme
|
||||
import com.meowarex.rlmobile.util.*
|
||||
|
||||
class SettingsModel(
|
||||
private val application: Application,
|
||||
private val activities: ActivityProvider,
|
||||
private val paths: PathManager,
|
||||
val preferences: PreferencesManager,
|
||||
) : ScreenModel {
|
||||
val installInfo = InstallInfo
|
||||
|
||||
var patchedApkExists by mutableStateOf(paths.patchedApk.exists())
|
||||
private set
|
||||
var showThemeDialog by mutableStateOf(false)
|
||||
private set
|
||||
var showInstallersDialog by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun showThemeDialog() {
|
||||
showThemeDialog = true
|
||||
}
|
||||
|
||||
fun hideThemeDialog() {
|
||||
showThemeDialog = false
|
||||
}
|
||||
|
||||
fun showInstallersDialog() {
|
||||
showInstallersDialog = true
|
||||
}
|
||||
|
||||
fun hideInstallersDialog() {
|
||||
showInstallersDialog = false
|
||||
}
|
||||
|
||||
fun setTheme(theme: Theme) {
|
||||
preferences.theme = theme
|
||||
}
|
||||
|
||||
fun setInstaller(installer: InstallerSetting) {
|
||||
preferences.installer = installer
|
||||
}
|
||||
|
||||
fun setKeepPatchedApks(value: Boolean) {
|
||||
preferences.keepPatchedApks = value
|
||||
}
|
||||
|
||||
fun clearCache() = screenModelScope.launchIO {
|
||||
paths.clearCache()
|
||||
|
||||
mainThread {
|
||||
patchedApkExists = false
|
||||
application.showToast(R.string.action_cleared_cache)
|
||||
}
|
||||
}
|
||||
|
||||
fun copyInstallInfo() {
|
||||
application.copyToClipboard(installInfo)
|
||||
application.showToast(R.string.action_copied)
|
||||
}
|
||||
|
||||
fun shareApk() {
|
||||
val file = paths.patchingWorkingDir.resolve("patched.apk")
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
/* context = */ application,
|
||||
/* authority = */ "${BuildConfig.APPLICATION_ID}.provider",
|
||||
/* file = */ file,
|
||||
/* displayName = */ "RadiantLyrics.apk",
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
.setType("application/vnd.android.package-archive")
|
||||
.putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.let {
|
||||
Intent.createChooser(
|
||||
/* target = */ it,
|
||||
/* title = */ application.getString(R.string.log_action_export_apk),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
activities.get<Activity>().startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(BuildConfig.TAG, "Failed to share APK", t)
|
||||
application.showToast(R.string.status_failed)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("KotlinConstantConditions")
|
||||
private val InstallInfo: String = """
|
||||
Radiant Lyrics Manager
|
||||
Version: ${BuildConfig.VERSION_NAME}
|
||||
Version Code: ${BuildConfig.VERSION_CODE}
|
||||
Release: ${if (BuildConfig.RELEASE) "Yes" else "No"}
|
||||
Git Branch: ${BuildConfig.GIT_BRANCH}
|
||||
Git Commit: ${BuildConfig.GIT_COMMIT}
|
||||
Git Changes: ${if (BuildConfig.GIT_LOCAL_CHANGES) "Yes" else "No"}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user