Compare commits

...
19 Commits
Author SHA1 Message Date
meowarex b175890e9d Merge pull request #84 from meowarex/dev
Forgot to bump .version <3
2026-08-14 06:38:52 +00:00
meowarex 4b1e9b5480 Forgot to bump .version <3 2026-08-14 06:38:46 +00:00
meoware.exe 4531e6e259 Merge pull request #83 from meowarex/dev
Kawarp Shader <3
2026-08-14 16:23:25 +10:00
meowarex 526e424621 Kawarp Shader <3 2026-08-14 06:21:00 +00:00
meoware.exe a7fa8f7d30 Merge pull request #81 from meowarex/dev
Forgot .version (again)..
2026-08-13 01:08:17 +10:00
meowarex 625d012e17 Forgot .version (again).. 2026-08-12 15:07:46 +00:00
meoware.exe 2cb0e1a67e Merge pull request #80 from meowarex/dev
Revert to Legacy Sliders <3
2026-08-13 00:53:30 +10:00
meowarex b1f50d4098 Revert to Legacy Sliders <3 2026-08-12 14:45:39 +00:00
meowarexandClaude Opus 5 24c429e8b9 1.0 Hotfix
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:00:43 +00:00
meoware.exe 0f00c4dca6 Merge pull request #79 from meowarex/dev
Manager UI | Compose Rewrite
2026-08-12 03:44:32 +10:00
meoware.exe 56ed288355 Merge pull request #78 from meowarex/compose-rewrite
UI Compose Rewrite
2026-08-12 03:43:47 +10:00
meowarex 5fadc4ebbc UI Compose Rewrite 2026-08-11 17:29:31 +00:00
meoware.exe 28cd296283 Merge pull request #77 from meowarex/dev
WIP | Syllable AI [RL API]
2026-08-11 20:26:40 +10:00
meowarex ba9bba2030 WIP | Syllable AI [RL API] 2026-08-11 07:57:30 +00:00
meowarex d44d833334 Gestures Rewrite 2026-08-11 03:22:28 +00:00
meoware.exe 8d06cea14d Merge pull request #76 from meowarex/dev
1.0 Release <3
2026-08-10 17:00:52 +10:00
meoware.exe b9baef4478 1.0 Release <3 2026-08-10 17:00:22 +10:00
meoware.exe 8fc0241611 Merge pull request #75 from meowarex/dev
Syllable Level Lyrics <3
2026-08-10 03:20:25 +10:00
meowarex a648e1a97c Syllable Level Lyrics <3 2026-08-09 17:11:50 +00:00
185 changed files with 16607 additions and 2848 deletions
+5 -1
View File
@@ -16,8 +16,12 @@ jobs:
- uses: actions/checkout@v4
- id: set
run: |
# .version now holds the full semver (e.g. 1.0.0) and is bumped per release.
# It used to be a "0.3" prefix with github.run_number appended as the patch,
# which cannot express an exact 1.0.0. versionCode still tracks run_number,
# so it stays monotonic regardless of what versionName says.
BASE=$(cat .version | tr -d '[:space:]')
echo "version=$BASE.${{ github.run_number }}" >> $GITHUB_OUTPUT
echo "version=$BASE" >> $GITHUB_OUTPUT
Build:
needs: Version
+3
View File
@@ -27,3 +27,6 @@ Reference/
!tidal-apk/*.apk
!tidal-apk/*.apkm
tidal-apk/*/
# Kawarp-AGSL library (CLONE HERE! the build tooling in tools/kawarp-build expects it)
tools/kawarp-agsl
+1 -1
View File
@@ -1 +1 @@
0.3
1.1.0
+7 -3
View File
@@ -23,13 +23,16 @@ val gitHasHasLocalChanges = providers.execIgnoreCode("git", "status", "-s").isNo
android {
namespace = "com.meowarex.rlmobile"
compileSdk = 36
// 37 is required to compile against compose 1.12.x. targetSdk deliberately stays at 36 —
// this only widens the API surface available at compile time, it does not opt the app in to
// Android 17's runtime behaviour changes.
compileSdk = 37
defaultConfig {
minSdk = 24
targetSdk = 36
versionCode = 10_03_00
versionName = "1.3.0"
versionCode = 62
versionName = "1.7.7"
vectorDrawables {
useSupportLibrary = true
@@ -161,6 +164,7 @@ kotlin {
"androidx.compose.foundation.ExperimentalFoundationApi",
"androidx.compose.foundation.layout.ExperimentalLayoutApi",
"androidx.compose.material3.ExperimentalMaterial3Api",
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.time.ExperimentalTime",
"kotlinx.serialization.ExperimentalSerializationApi",
)
+268 -44
View File
@@ -14,17 +14,16 @@
"DebugMenuUnlock"
],
"disables": [
"LyricsDisableCover",
"LyricsReplaceLyricsButton",
"LyricsReplaceShareButton",
"LyricsRlApi",
"LyricsKeepControlsVisible",
"PlayerBackdrop",
"QualityBadgeColors",
"PlayerMovePlayingFrom",
"PlayerOneHanded",
"LyricsProgressPill",
"MiniPlayerRedesign"
"LyricsDisableCover",
"LyricsReplaceLyricsButton",
"LyricsReplaceShareButton",
"LyricsKeepControlsVisible"
]
},
{
@@ -36,7 +35,7 @@
"lyrics-rl-api-isrc.patch"
],
"title": "Radiant Lyrics API - WIP",
"description": "Use Radiant Lyrics API to fetch Lyrics (Higher quality & More providers). Pick how the highlight moves: whole Line or word-by-word (Word).",
"description": "Use Radiant Lyrics API to fetch Lyrics (Syllable Level & Higher Quality)",
"default": false,
"defaultVariantIndex": 0,
"variants": [
@@ -49,6 +48,68 @@
"fileNames": [
"lyrics-rl-api-word.patch"
]
},
{
"title": "Syllable",
"fileNames": [
"lyrics-rl-api-syllable.patch"
]
}
],
"extensionFiles": [
"radiant/RLAPILyricsHook.smali",
"radiant/RLAPILyricsWorker.smali",
"radiant/IsrcCache.smali",
"radiant/WordLyrics.smali",
"radiant/WordLyricsTick.smali",
"radiant/SylBrush.smali",
"radiant/SylLayout.smali"
],
"advancedOptions": [
{
"type": "toggle",
"key": "romanize",
"title": "Romanization",
"description": "Return a romanized transcription and show that instead of the original script",
"default": false,
"token": "RL_ROMANIZE",
"inline": true
},
{
"type": "toggle",
"key": "synthesize",
"title": "WIP | AI Generate Syllables",
"description": "[Only works with API results] - Generates Syllable timings from Line timings using a self trained AI model ",
"default": false,
"inline": true,
"token": "RL_SYNTHESIZE"
},
{
"type": "toggle",
"key": "context_aware",
"title": "Context Aware Lyrics",
"description": "Display voice context cues (adlibs and duet singers get displayed seperately)",
"default": true,
"token": "RL_CTX_ON"
},
{
"type": "choice",
"key": "context_aware_mode",
"title": "Show",
"description": "",
"entries": [
"Adlibs",
"Singers",
"Both"
],
"values": [
"0x1",
"0x2",
"0x3"
],
"defaultIndex": 0,
"requiresOption": "context_aware",
"token": "RL_CTX"
}
]
},
@@ -57,36 +118,29 @@
"order": 30,
"fileNames": [],
"title": "Player Backdrop",
"description": "Lets you fully adjust how the player backdrop blur looks and behaves",
"description": "Swap the player backdrop for the Kawarp shader, or fine-tune TIDAL's own blurred cover",
"default": true,
"defaultVariantIndex": 2,
"defaultVariantIndex": 1,
"variants": [
{
"title": "Static",
"fileNames": [
"player-backdrop-static.patch"
],
"extensionFiles": [
"radiant/BackdropFx.smali"
]
},
{
"title": "Animated",
"fileNames": [
"player-backdrop-animated.patch"
],
"extensionFiles": [
"radiant/BackdropFx.smali"
]
},
{
"title": "Playback",
"title": "Tidal",
"fileNames": [
"player-backdrop-playback.patch"
],
"extensionFiles": [
"radiant/BackdropFx.smali"
]
},
{
"title": "Radiant",
"fileNames": [
"player-backdrop-kawarp.patch"
],
"extensionFiles": [
"radiant/Kawarp.smali",
"dev/kawarp/KawarpEngine.smali"
],
"minSdk": 33
}
],
"advancedOptions": [
@@ -104,7 +158,8 @@
"encode": {
"kind": "int",
"scale": 1.0
}
},
"requiresVariant": 0
},
{
"type": "slider",
@@ -120,7 +175,8 @@
"encode": {
"kind": "floatBits",
"scale": 0.02
}
},
"requiresVariant": 0
},
{
"type": "slider",
@@ -136,7 +192,8 @@
"encode": {
"kind": "floatBits",
"scale": 0.02
}
},
"requiresVariant": 0
},
{
"type": "slider",
@@ -152,7 +209,170 @@
"encode": {
"kind": "floatBits",
"scale": 0.02
}
},
"requiresVariant": 0
},
{
"type": "toggle",
"key": "kawarp_reactive",
"title": "Playback Reactive",
"description": "Cover shader reacts to playback state by freezing/resuming",
"default": true,
"token": "RL_KW_REACTIVE",
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_blur",
"title": "Blur Passes",
"description": "Kawase blur passes, higher is softer but costs more GPU",
"default": 5,
"min": 1,
"max": 40,
"steps": 38,
"displayAsPercent": false,
"token": "RL_KW_BLUR",
"encode": {
"kind": "int",
"scale": 1.0
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_warp",
"title": "Warp Intensity",
"description": "Strength of the fluidity effect",
"default": 100,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_KW_WARP",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_speed",
"title": "Animation Speed",
"description": "How fast the backdrop flows",
"default": 175,
"min": 0,
"max": 500,
"steps": 19,
"displayAsPercent": true,
"token": "RL_KW_SPEED",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_scale",
"title": "Backdrop Scale",
"description": "Zoom level of the effect",
"default": 100,
"min": 10,
"max": 400,
"steps": 12,
"displayAsPercent": true,
"token": "RL_KW_SCALE",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_contrast",
"title": "Contrast",
"description": "Contrast of the backdrop",
"default": 125,
"min": 0,
"max": 300,
"steps": 11,
"displayAsPercent": true,
"token": "RL_KW_CONTRAST",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_saturation",
"title": "Saturation",
"description": "Colour intensity of the backdrop",
"default": 125,
"min": 0,
"max": 400,
"steps": 15,
"displayAsPercent": true,
"token": "RL_KW_SAT",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_brightness",
"title": "Brightness",
"description": "The value of Brightness applied to the backdrop",
"default": 100,
"min": 0,
"max": 200,
"steps": 19,
"displayAsPercent": true,
"token": "RL_KW_BRIGHT",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_darken",
"title": "Auto Darken Bright Covers",
"description": "Prevents bright covers from making text unreadable",
"default": 80,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_KW_DARKEN",
"encode": {
"kind": "floatBits",
"scale": 0.01
},
"requiresVariant": 1
},
{
"type": "slider",
"key": "kawarp_dither",
"title": "Dithering",
"description": "Breaks up color banding in smooth gradients",
"default": 15,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": false,
"token": "RL_KW_DITHER",
"encode": {
"kind": "floatBits",
"scale": 0.001
},
"requiresVariant": 1
},
{
"type": "toggle",
@@ -317,15 +537,19 @@
"description": "Adds swipe up gesture to the mini-player to reopen the player",
"default": true,
"fileNames": [
"mini-player-gestures.patch"
"mini-player-up-gesture.patch",
"player-swipe-gesture.patch",
"lyrics-gesture-scroll-guard.patch"
],
"extensionFiles": [
"radiant/MiniPlayerGestures.smali",
"radiant/MiniPlayerGestures$Gesture.smali",
"radiant/MiniPlayerGestures$FeedbackLayer.smali",
"radiant/MiniPlayerGestures$FeedbackResetAnimator.smali",
"radiant/MiniPlayerGestures$RootGesture.smali",
"radiant/MiniPlayerGestures$ApplyPending.smali"
"radiant/gestures/MiniPlayerGestures.smali",
"radiant/gestures/MiniPlayerGestures$Gesture.smali",
"radiant/gestures/MiniPlayerGestures$FeedbackLayer.smali",
"radiant/gestures/MiniPlayerGestures$FeedbackResetAnimator.smali",
"radiant/gestures/MiniPlayerGestures$RootGesture.smali",
"radiant/gestures/MiniPlayerGestures$ApplyPending.smali",
"radiant/gestures/LyricsDrag.smali",
"radiant/gestures/LyricsDrag$Guard.smali"
]
},
{
@@ -352,14 +576,14 @@
"default": false,
"requiresOption": "gestures",
"fileNames": [
"mini-player-gestures-left-right.patch"
"mini-player-left-right-gesture.patch"
],
"extensionFiles": [
"radiant/MiniPlayerTrackGestures.smali",
"radiant/MiniPlayerTrackGestures$Gesture.smali",
"radiant/MiniPlayerTrackGestures$OffsetLayer.smali",
"radiant/MiniPlayerTrackGestures$ResetAnimator.smali",
"radiant/MiniPlayerTrackGestures$TextDraw.smali",
"radiant/gestures/MiniPlayerTrackGestures.smali",
"radiant/gestures/MiniPlayerTrackGestures$Gesture.smali",
"radiant/gestures/MiniPlayerTrackGestures$OffsetLayer.smali",
"radiant/gestures/MiniPlayerTrackGestures$ResetAnimator.smali",
"radiant/gestures/MiniPlayerTrackGestures$TextDraw.smali",
"com/tidal/android/feature/appscaffold/ui/r$c.smali"
]
}
@@ -22,11 +22,15 @@ import com.meowarex.rlmobile.MainActivity.Companion.EXTRA_PACKAGE_NAME
import com.meowarex.rlmobile.manager.*
import com.meowarex.rlmobile.patcher.InstallMetadata
import com.meowarex.rlmobile.ui.screens.home.HomeScreen
import com.meowarex.rlmobile.ui.legacy.screens.home.HomeScreen as LegacyHomeScreen
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
import com.meowarex.rlmobile.ui.legacy.screens.patching.PatchingScreen as LegacyPatchingScreen
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen
import com.meowarex.rlmobile.ui.legacy.screens.permissions.PermissionsScreen as LegacyPermissionsScreen
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import com.meowarex.rlmobile.ui.theme.UiStyle
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
import com.meowarex.rlmobile.util.*
import com.github.diamondminer88.zip.ZipReader
@@ -63,6 +67,7 @@ class MainActivity : ComponentActivity() {
ManagerTheme(
theme = preferences.theme,
uiStyle = preferences.uiStyle,
dynamicColor = preferences.dynamicColor,
) {
if (BuildConfig.RELEASE) {
@@ -73,14 +78,20 @@ class MainActivity : ComponentActivity() {
CompositionLocalProvider(
LocalNavigatorSaver provides parcelableNavigatorSaver(),
) {
val legacyUi = preferences.uiStyle == UiStyle.Legacy
key(legacyUi) {
Navigator(
screen = HomeScreen(),
screen = if (legacyUi) LegacyHomeScreen() else HomeScreen(),
onBackPressed = null,
) { navigator ->
// Open the permissions screen whenever permissions are insufficient
LaunchedEffect(permissions.requiredPermsGranted) {
if (!permissions.requiredPermsGranted)
navigator.pushOnce(PermissionsScreen())
if (!permissions.requiredPermsGranted) {
navigator.pushOnce(
if (legacyUi) LegacyPermissionsScreen() else PermissionsScreen()
)
}
}
DisposableEffect(Unit) {
@@ -104,6 +115,7 @@ class MainActivity : ComponentActivity() {
)
)
}
}
overlays.Overlays()
}
@@ -184,7 +196,11 @@ class MainActivity : ComponentActivity() {
val patchOptions = metadata?.options
?: PatchOptions.Default.copy(packageName = packageName)
return PatchingScreen(patchOptions)
return if (preferences.uiStyle == UiStyle.Legacy) {
LegacyPatchingScreen(patchOptions)
} else {
PatchingScreen(patchOptions)
}
}
companion object {
@@ -4,11 +4,14 @@ import android.content.SharedPreferences
import androidx.compose.runtime.Stable
import com.meowarex.rlmobile.manager.base.BasePreferenceManager
import com.meowarex.rlmobile.ui.theme.Theme
import com.meowarex.rlmobile.ui.theme.UiStyle
@Stable
class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) {
var theme by enumPreference("theme", Theme.System)
var dynamicColor by booleanPreference("dynamic_color", true)
var uiStyle by enumPreference("ui_style", UiStyle.Radiant)
// Off by default — Radiant ships its own palette. Opting in hands theming back to Monet.
var dynamicColor by booleanPreference("dynamic_color", false)
var devMode by booleanPreference("dev_mode", false)
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller)
var keepPatchedApks by booleanPreference("keep_patched_apks", false)
@@ -1,13 +1,12 @@
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.ui.components.radiant.RadiantIconButton
import com.meowarex.rlmobile.util.back
/**
@@ -18,14 +17,10 @@ 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),
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
subtle = true,
onClick = { navigator?.back(activity) },
)
}
@@ -1,13 +1,11 @@
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
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonStyle
@Composable
fun MainActionButton(
@@ -15,35 +13,33 @@ fun MainActionButton(
icon: Painter,
onClick: () -> Unit,
enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
style: RadiantButtonStyle = RadiantButtonStyle.Accent,
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),
)
) = RadiantButton(
text = text,
icon = icon,
onClick = onClick,
enabled = enabled,
style = style,
size = RadiantButtonSize.Large,
fillWidth = true,
modifier = modifier,
)
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
fun DangerActionButton(
text: String,
icon: Painter,
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier,
) = RadiantButton(
text = text,
icon = icon,
onClick = onClick,
enabled = enabled,
style = RadiantButtonStyle.Danger,
size = RadiantButtonSize.Large,
fillWidth = true,
modifier = modifier,
)
@@ -1,17 +1,21 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.platform.LocalUriHandler
import androidx.compose.ui.res.colorResource
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
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
@Composable
fun ProjectHeader(modifier: Modifier = Modifier) {
@@ -22,30 +26,37 @@ fun ProjectHeader(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.clip(MaterialShapes.Cookie9Sided.toShape())
.background(colorResource(R.color.ic_launcher_background)),
)
Text(
text = stringResource(R.string.rlmobile),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp)
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.app_description),
style = MaterialTheme.typography.titleSmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 6.dp),
) {
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))
}
RadiantButton(
text = stringResource(R.string.github),
icon = painterResource(R.drawable.ic_account_github_white_24dp),
onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") },
)
}
}
}
@@ -1,15 +1,13 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.animation.*
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.material3.MaterialTheme
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
import com.meowarex.rlmobile.ui.components.radiant.RadiantIconButton
@Composable
fun ResetToDefaultButton(
@@ -23,15 +21,12 @@ fun ResetToDefaultButton(
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),
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_refresh),
contentDescription = stringResource(R.string.action_reset_default),
tint = MaterialTheme.colorScheme.secondary,
subtle = true,
onClick = onClick,
)
}
}
@@ -1,25 +1,48 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.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.theme.glassSurface
/**
* Status pill
*/
@Composable
fun Tag(text: String) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(6.dp),
fun Tag(
text: String,
icon: Painter? = null,
tint: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.glassSurface(CircleShape, tint = tint)
.clip(CircleShape)
.padding(horizontal = 9.dp, vertical = 4.dp),
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
tint = tint,
modifier = Modifier.size(12.dp),
)
}
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
color = tint,
)
}
}
@@ -1,35 +1,47 @@
package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.background
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.clip
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Section header
*/
@Composable
fun TextDivider(
text: String,
style: TextStyle = MaterialTheme.typography.bodyMedium,
style: TextStyle = MaterialTheme.typography.labelLarge,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
) {
HorizontalDivider(Modifier.weight(1f))
Text(
text = text,
style = style,
color = MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.SemiBold,
Box(
modifier = Modifier
.size(width = 14.dp, height = 3.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
HorizontalDivider(Modifier.weight(1f))
Text(
text = text.uppercase(),
style = style.copy(letterSpacing = 1.2.sp),
color = MaterialTheme.colorScheme.primary,
)
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier.weight(1f),
)
}
}
@@ -1,62 +1,37 @@
package com.meowarex.rlmobile.ui.components.dialogs
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
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
import com.meowarex.rlmobile.ui.components.radiant.RadiantDialog
@Composable
fun InstallerAbortDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
RadiantDialog(
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,
)
},
title = stringResource(R.string.installer_abort_title),
icon = {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
modifier = Modifier.size(32.dp),
modifier = Modifier.size(26.dp),
)
},
containerColor = MaterialTheme.colorScheme.errorContainer,
iconContentColor = MaterialTheme.colorScheme.onErrorContainer,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer,
textContentColor = MaterialTheme.colorScheme.onErrorContainer
)
confirmText = stringResource(R.string.action_exit_anyways),
onConfirm = onConfirm,
destructive = true,
dismissText = stringResource(R.string.action_cancel),
onDismissAction = onDismiss,
) {
Text(stringResource(R.string.installer_abort_body))
}
}
@@ -13,11 +13,10 @@ 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.components.radiant.*
import com.meowarex.rlmobile.ui.theme.customColors
@Composable
@@ -29,70 +28,58 @@ fun PlayProtectDialog(
var neverShow by rememberSaveable { mutableStateOf(false) }
val rememberedNeverShow by rememberUpdatedState(neverShow)
AlertDialog(
RadiantDialog(
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))
}
},
title = stringResource(R.string.play_protect_warning_title),
icon = {
Icon(
painter = painterResource(R.drawable.ic_protect_warning),
tint = MaterialTheme.customColors.warning,
contentDescription = null,
modifier = Modifier.size(36.dp),
modifier = Modifier.size(28.dp),
)
},
title = { Text(stringResource(R.string.play_protect_warning_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(stringResource(R.string.play_protect_warning_desc))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { neverShow = !rememberedNeverShow },
),
) {
Text(
text = stringResource(R.string.play_protect_warning_desc),
textAlign = TextAlign.Center,
RadiantCheckbox(
checked = neverShow,
onCheckedChange = { neverShow = it },
)
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))
}
Text(stringResource(R.string.play_protect_warning_disable))
}
},
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
modifier = Modifier
.padding(25.dp),
)
// Both actions are equal, they share a row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
modifier = Modifier.fillMaxWidth(),
) {
RadiantButton(
text = stringResource(R.string.play_protect_warning_open_gpp),
onClick = activity::launchPlayProtect,
style = RadiantButtonStyle.Glass,
size = RadiantButtonSize.Small,
)
RadiantButton(
text = stringResource(R.string.play_protect_warning_ok),
onClick = { onDismiss(rememberedNeverShow) },
style = RadiantButtonStyle.Accent,
size = RadiantButtonSize.Small,
)
}
}
}
}
private fun Activity.launchPlayProtect() {
@@ -0,0 +1,321 @@
package com.meowarex.rlmobile.ui.components.radiant
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.theme.*
/**
* How much a button shrinks while held.
*/
private const val PRESS_SCALE = 0.965f
enum class RadiantButtonStyle {
/** Gradient fill + glow. */
Accent,
/** Translucent accent wash + hairline. */
Glass,
/** Hairline only. */
Ghost,
/** Error-tinted glass. */
Danger,
}
/**
* Sizes are matched to the pre-redesign UI.
*/
enum class RadiantButtonSize(
val height: Dp,
val horizontalPadding: Dp,
val iconSize: Dp,
val corner: Dp,
) {
Small(36.dp, 14.dp, 16.dp, 12.dp),
Medium(40.dp, 18.dp, 18.dp, 14.dp),
Large(46.dp, 22.dp, 20.dp, 16.dp),
}
/**
* The app's button. (forgot specifically what this means but.. claude did this part)
*/
@Composable
fun RadiantButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: Painter? = null,
style: RadiantButtonStyle = RadiantButtonStyle.Glass,
size: RadiantButtonSize = RadiantButtonSize.Medium,
enabled: Boolean = true,
fillWidth: Boolean = false,
) {
val ui = radiantStyle
// Legacy renders the old Material widget
if (ui.legacy) {
LegacyButton(
text = text,
onClick = onClick,
icon = icon,
variant = style,
enabled = enabled,
fillWidth = fillWidth,
modifier = modifier,
)
return
}
val interaction = remember(::MutableInteractionSource)
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed && enabled) ui.pressScale else 1f,
label = "RadiantButtonScale",
)
val shape: Shape = RoundedCornerShape(size.corner)
val scheme = MaterialTheme.colorScheme
val contentColor = when {
!enabled -> scheme.onSurface.copy(alpha = 0.38f)
style == RadiantButtonStyle.Accent -> scheme.onPrimary
style == RadiantButtonStyle.Danger -> scheme.error
else -> scheme.primary
}
// Glow only on the accent style
val glowModifier = if (style == RadiantButtonStyle.Accent && enabled) {
Modifier.glow(
color = scheme.primary,
radius = if (pressed) 22.dp else 14.dp,
shape = shape,
)
} else {
Modifier
}
val fillModifier = when (style) {
RadiantButtonStyle.Accent -> Modifier.background(
brush = if (enabled) accentBrush else accentBrushDisabled,
shape = shape,
)
RadiantButtonStyle.Glass -> Modifier.glassSurface(
shape = shape,
tint = scheme.primary,
fillAlpha = if (pressed) RadiantDesign.GLASS_ALPHA_PRESSED else RadiantDesign.GLASS_ALPHA,
)
RadiantButtonStyle.Danger -> Modifier.glassSurface(
shape = shape,
tint = scheme.error,
fillAlpha = if (pressed) RadiantDesign.GLASS_ALPHA_PRESSED else RadiantDesign.GLASS_ALPHA,
)
RadiantButtonStyle.Ghost -> Modifier.border(
width = RadiantDesign.Hairline,
brush = hairlineBrush(scheme.onSurface, RadiantDesign.BORDER_ALPHA * 2f),
shape = shape,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.scale(scale)
.then(if (fillWidth) Modifier.fillMaxWidth() else Modifier)
.height(size.height)
.then(glowModifier)
.then(fillModifier)
.clip(shape)
.clickable(
interactionSource = interaction,
indication = null,
enabled = enabled,
role = Role.Button,
onClick = onClick,
)
.padding(horizontal = size.horizontalPadding)
.alpha(if (enabled) 1f else 0.6f),
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(size.iconSize),
)
}
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = contentColor,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
/**
* Icon-only sibling of RadiantButton.
*/
@Composable
fun RadiantIconButton(
icon: Painter,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
subtle: Boolean = false,
accent: Boolean = false,
size: Dp = 40.dp,
tint: Color? = null,
) {
val ui = radiantStyle
if (ui.legacy) {
val content = @Composable {
Icon(
painter = icon,
contentDescription = contentDescription,
tint = tint ?: LocalContentColor.current,
)
}
if (subtle) {
IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { content() }
} else {
FilledTonalIconButton(onClick = onClick, enabled = enabled, modifier = modifier) { content() }
}
return
}
val interaction = remember(::MutableInteractionSource)
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed && enabled) 0.9f else 1f,
label = "RadiantIconButtonScale",
)
val shape = RoundedCornerShape(size / 3f)
val scheme = MaterialTheme.colorScheme
val container = when {
subtle -> Modifier
accent -> Modifier
.glow(scheme.primary, if (pressed) 18.dp else 12.dp, shape)
.background(accentBrush, shape)
else -> Modifier.glassSurface(shape = shape, tint = scheme.primary)
}
val contentColor = tint ?: when {
!enabled -> scheme.onSurface.copy(alpha = 0.38f)
accent -> scheme.onPrimary
subtle -> LocalContentColor.current
else -> scheme.primary
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.scale(scale)
.size(size)
.then(container)
.clip(shape)
.clickable(
interactionSource = interaction,
indication = null,
enabled = enabled,
role = Role.Button,
onClick = onClick,
),
) {
Icon(
painter = icon,
contentDescription = contentDescription,
tint = contentColor,
modifier = Modifier.size(size * 0.48f),
)
}
}
/** Border stroke matching the hairline (technically a duplicate but i was lazy)*/
@Composable
fun radiantHairline(
color: Color = MaterialTheme.colorScheme.onSurface,
alpha: Float = RadiantDesign.BORDER_ALPHA,
) = BorderStroke(RadiantDesign.Hairline, hairlineBrush(color, alpha))
/**
* Material equivalents for RadiantButtonStyle.
*/
@Composable
private fun LegacyButton(
text: String,
onClick: () -> Unit,
icon: Painter?,
variant: RadiantButtonStyle,
enabled: Boolean,
fillWidth: Boolean,
modifier: Modifier = Modifier,
) {
val m = if (fillWidth) modifier.fillMaxWidth() else modifier
val content: @Composable RowScope.() -> Unit = {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
}
Text(text = text, maxLines = 1)
}
when (variant) {
RadiantButtonStyle.Accent ->
Button(onClick = onClick, enabled = enabled, modifier = m, content = content)
RadiantButtonStyle.Glass ->
FilledTonalButton(onClick = onClick, enabled = enabled, modifier = m, content = content)
RadiantButtonStyle.Ghost ->
OutlinedButton(onClick = onClick, enabled = enabled, modifier = m, content = content)
RadiantButtonStyle.Danger -> FilledTonalButton(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier = m,
content = content,
)
}
}
@@ -0,0 +1,181 @@
package com.meowarex.rlmobile.ui.components.radiant
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.CompositionLocalProvider
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.Shape
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.meowarex.rlmobile.ui.theme.*
/**
* The house card. (idk what a house card is, GPT 5.6 said it and i thought it was funny so i left it in everywhere)
*/
@Composable
fun RadiantCard(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(24.dp),
base: Color = MaterialTheme.colorScheme.surfaceContainerLow,
active: Boolean = false,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
if (radiantStyle.legacy) {
// Material's own elevated card.
ElevatedCard(
shape = shape,
modifier = modifier
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
content = content,
)
return
}
Column(
modifier = modifier
.radiantSurface(shape = shape, base = base, active = active)
.clip(shape)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
content = content,
)
}
/**
* Dialog shell.
*/
@Composable
fun RadiantDialog(
onDismissRequest: () -> Unit,
title: String,
modifier: Modifier = Modifier,
icon: @Composable (() -> Unit)? = null,
confirmText: String? = null,
onConfirm: (() -> Unit)? = null,
confirmEnabled: Boolean = true,
dismissText: String? = null,
onDismissAction: (() -> Unit)? = null,
destructive: Boolean = false,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(30.dp)
if (radiantStyle.legacy) {
AlertDialog(
onDismissRequest = onDismissRequest,
icon = icon,
title = { Text(title) },
text = { Column(content = content) },
confirmButton = {
if (confirmText != null) {
TextButton(
onClick = onConfirm ?: onDismissRequest,
enabled = confirmEnabled,
) { Text(confirmText) }
}
},
dismissButton = dismissText?.let {
{
TextButton(onClick = onDismissAction ?: onDismissRequest) { Text(it) }
}
},
modifier = modifier,
)
return
}
Dialog(onDismissRequest = onDismissRequest) {
Column(
modifier = modifier
.fillMaxWidth()
.radiantSurface(
shape = shape,
base = MaterialTheme.colorScheme.surfaceContainer,
)
.clip(shape)
.padding(24.dp),
) {
if (icon != null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(bottom = 14.dp)
.size(44.dp)
.clip(RoundedCornerShape(15.dp))
.glassSurface(RoundedCornerShape(15.dp)),
) {
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.primary,
) { icon() }
}
}
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 10.dp),
)
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant,
) {
ProvideTextStyle(MaterialTheme.typography.bodyMedium) {
content()
}
}
if (confirmText != null || dismissText != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 22.dp)
.fillMaxWidth(),
) {
if (dismissText != null) {
RadiantButton(
text = dismissText,
onClick = onDismissAction ?: onDismissRequest,
style = RadiantButtonStyle.Ghost,
size = RadiantButtonSize.Small,
)
}
if (confirmText != null) {
RadiantButton(
text = confirmText,
onClick = onConfirm ?: onDismissRequest,
enabled = confirmEnabled,
style = if (destructive) {
RadiantButtonStyle.Danger
} else {
RadiantButtonStyle.Accent
},
size = RadiantButtonSize.Small,
)
}
}
}
}
}
}
/**
* Section rule. (short accent segment fading into a hairline)
*/
@Composable
fun RadiantDivider(
modifier: Modifier = Modifier,
) {
HorizontalDivider(
thickness = RadiantDesign.Hairline,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = RadiantDesign.BORDER_ALPHA * 1.4f),
modifier = modifier,
)
}
@@ -0,0 +1,237 @@
package com.meowarex.rlmobile.ui.components.radiant
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.theme.*
/**
* Text field.
*/
@Composable
fun RadiantTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
isError: Boolean = false,
enabled: Boolean = true,
singleLine: Boolean = true,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailing: @Composable (() -> Unit)? = null,
) {
if (radiantStyle.native) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
isError = isError,
singleLine = singleLine,
visualTransformation = visualTransformation,
placeholder = placeholder?.let { { Text(it) } },
trailingIcon = trailing,
modifier = modifier.fillMaxWidth(),
)
return
}
val scheme = MaterialTheme.colorScheme
val interaction = remember(::MutableInteractionSource)
val focused by interaction.collectIsFocusedAsState()
val shape = RoundedCornerShape(16.dp)
val borderColor by animateColorAsState(
targetValue = when {
isError -> scheme.error
focused -> scheme.primary
else -> scheme.onSurface.copy(alpha = RadiantDesign.BORDER_ALPHA * 2f)
},
label = "TextFieldBorder",
)
val borderWidth by animateDpAsState(
targetValue = if (focused || isError) 2.dp else RadiantDesign.Hairline,
label = "TextFieldBorderWidth",
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.fillMaxWidth()
.background(scheme.surfaceContainerLowest.copy(alpha = 0.7f), shape)
.border(borderWidth, borderColor, shape)
.clip(shape)
.padding(horizontal = 16.dp, vertical = 4.dp)
.heightIn(min = 52.dp),
) {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier.weight(1f),
) {
if (value.isEmpty() && placeholder != null) {
Text(
text = placeholder,
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
singleLine = singleLine,
interactionSource = interaction,
visualTransformation = visualTransformation,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = scheme.onSurface),
cursorBrush = SolidColor(scheme.primary),
modifier = Modifier.fillMaxWidth(),
)
}
trailing?.invoke()
}
}
/**
* Slider. (i prefer the old on tbh so i reverted it back <3)
*/
@Composable
fun RadiantSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
enabled: Boolean = true,
showStopIndicator: Boolean = true,
onValueChangeFinished: (() -> Unit)? = null,
) {
if (showStopIndicator) {
Slider(
value = value,
onValueChange = onValueChange,
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
steps = steps,
enabled = enabled,
modifier = modifier,
)
} else {
Slider(
value = value,
onValueChange = onValueChange,
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
steps = steps,
enabled = enabled,
modifier = modifier,
track = { SliderDefaults.Track(sliderState = it, drawStopIndicator = null) },
)
}
}
/**
* Pill segmented control.
*/
@Composable
fun RadiantSegmented(
options: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
if (radiantStyle.native) {
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
options.forEachIndexed { index, label ->
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
icon = {},
label = { Text(text = label, maxLines = 1) },
)
}
}
return
}
val scheme = MaterialTheme.colorScheme
val trackShape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.fillMaxWidth()
.background(scheme.surfaceContainerLowest.copy(alpha = 0.8f), trackShape)
.border(
RadiantDesign.Hairline,
hairlineBrush(scheme.onSurface, RadiantDesign.BORDER_ALPHA),
trackShape,
)
.clip(trackShape)
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
options.forEachIndexed { index, label ->
val selected = index == selectedIndex
val pillShape = RoundedCornerShape(13.dp)
val alpha by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
label = "SegmentedPill",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.height(34.dp)
.clip(pillShape)
.then(
if (alpha > 0.01f) {
Modifier.background(
accentBrush,
pillShape,
alpha = alpha,
)
} else Modifier
)
.clickable(
enabled = enabled,
role = Role.RadioButton,
onClick = { onSelect(index) },
),
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) scheme.onPrimary else scheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
/** something.. */
@@ -0,0 +1,245 @@
package com.meowarex.rlmobile.ui.components.radiant
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.theme.*
private val TrackWidth = 52.dp
private val TrackHeight = 32.dp
private val ThumbSize = 24.dp
private val ThumbInset = 4.dp
/**
* Capsule switch.
*/
@Composable
fun RadiantSwitch(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
) {
if (radiantStyle.native) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
interactionSource = interactionSource,
modifier = modifier,
)
return
}
val scheme = MaterialTheme.colorScheme
val offset by animateDpAsState(
targetValue = if (checked) TrackWidth - ThumbSize - ThumbInset else ThumbInset,
animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMediumLow),
label = "SwitchThumbOffset",
)
val trackColor by animateColorAsState(
targetValue = if (checked) Color.Transparent else scheme.surfaceContainerHighest,
label = "SwitchTrack",
)
val thumbColor by animateColorAsState(
targetValue = if (checked) scheme.onPrimary else scheme.outline,
label = "SwitchThumb",
)
val trackShape = RoundedCornerShape(percent = 50)
Box(
contentAlignment = Alignment.CenterStart,
modifier = modifier
.alpha(if (enabled) 1f else 0.45f)
.then(
if (checked && enabled) {
Modifier.glow(scheme.primary, 14.dp, trackShape, alpha = 0.5f)
} else Modifier
)
.size(TrackWidth, TrackHeight)
.clip(trackShape)
.then(if (checked) Modifier.background(accentBrush, trackShape) else Modifier)
.background(trackColor, trackShape)
.border(
width = RadiantDesign.Hairline,
brush = hairlineBrush(
color = if (checked) scheme.primary else scheme.onSurface,
alpha = if (checked) RadiantDesign.BORDER_ALPHA_ACTIVE else RadiantDesign.BORDER_ALPHA * 2f,
),
shape = trackShape,
)
.then(
if (onCheckedChange != null) {
Modifier.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
interactionSource = interactionSource,
indication = null,
onValueChange = onCheckedChange,
)
} else Modifier
),
) {
Box(
modifier = Modifier
.padding(start = offset)
.size(ThumbSize)
.clip(CircleShape)
.background(thumbColor, CircleShape),
)
}
}
/**
* Ring & Core radio button.
*/
@Composable
fun RadiantRadio(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
) {
if (radiantStyle.native) {
RadioButton(
selected = selected,
onClick = onClick,
enabled = enabled,
interactionSource = interactionSource,
modifier = modifier,
)
return
}
val scheme = MaterialTheme.colorScheme
val coreScale by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMediumLow),
label = "RadioCore",
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.alpha(if (enabled) 1f else 0.45f)
.size(20.dp)
.clip(CircleShape)
.border(
width = 2.dp,
color = if (selected) scheme.primary else scheme.outline.copy(alpha = 0.6f),
shape = CircleShape,
)
.then(
if (onClick != null) {
Modifier.selectable(
selected = selected,
enabled = enabled,
role = Role.RadioButton,
interactionSource = interactionSource,
indication = null,
onClick = onClick,
)
} else Modifier
),
) {
Box(
modifier = Modifier
.scale(coreScale)
.size(10.dp)
.clip(CircleShape)
.background(accentBrush, CircleShape),
)
}
}
/**
* Squircle checkbox that fills with the accent gradient when checked.
*/
@Composable
fun RadiantCheckbox(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
if (radiantStyle.native) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
modifier = modifier,
)
return
}
val scheme = MaterialTheme.colorScheme
val shape = RoundedCornerShape(7.dp)
val fillScale by animateFloatAsState(
targetValue = if (checked) 1f else 0f,
animationSpec = spring(dampingRatio = 0.5f, stiffness = Spring.StiffnessMediumLow),
label = "CheckboxFill",
)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.alpha(if (enabled) 1f else 0.45f)
.size(20.dp)
.clip(shape)
.border(
width = 2.dp,
color = if (checked) Color.Transparent else scheme.outline.copy(alpha = 0.6f),
shape = shape,
)
.then(
if (onCheckedChange != null) {
Modifier.toggleable(
value = checked,
enabled = enabled,
role = Role.Checkbox,
onValueChange = onCheckedChange,
)
} else Modifier
),
) {
Box(
modifier = Modifier
.scale(fillScale)
.matchParentSize()
.clip(shape)
.background(accentBrush, shape),
)
if (checked) {
Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = null,
tint = scheme.onPrimary,
modifier = Modifier.size(13.dp),
)
}
}
}
@@ -0,0 +1,58 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
/**
* Where a row sits inside a settings group. (decides corner merging)
*/
enum class GroupPosition {
Top,
Middle,
Bottom,
Single;
val shape: Shape
get() = when (this) {
Top -> RoundedCornerShape(OUTER, OUTER, INNER, INNER)
Middle -> RoundedCornerShape(INNER)
Bottom -> RoundedCornerShape(INNER, INNER, OUTER, OUTER)
Single -> RoundedCornerShape(OUTER)
}
private companion object {
val OUTER = 24.dp
val INNER = 6.dp
}
}
/**
* Resolves the position of index [index] in a group of [count] rows.
*/
fun groupPosition(index: Int, count: Int): GroupPosition = when {
count <= 1 -> GroupPosition.Single
index == 0 -> GroupPosition.Top
index == count - 1 -> GroupPosition.Bottom
else -> GroupPosition.Middle
}
/**
* Container for settings rows. (3dp gap separates the tiles)
*/
@Composable
fun SettingsGroup(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
content = content,
)
}
@@ -12,6 +12,6 @@ fun SettingsHeader(
) {
TextDivider(
text = text,
modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp)
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 26.dp, bottom = 12.dp),
)
}
@@ -1,57 +1,93 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.foundation.shape.RoundedCornerShape
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.unit.dp
import com.meowarex.rlmobile.ui.theme.accentBrush
import com.meowarex.rlmobile.ui.theme.glow
import com.meowarex.rlmobile.ui.theme.radiantStyle
import com.meowarex.rlmobile.ui.theme.radiantSurface
/**
* A settings row rendered as a tile on the house surface.
*/
@Composable
fun SettingsItem(
text: @Composable () -> Unit,
secondaryText: @Composable (() -> Unit) = { },
icon: @Composable (() -> Unit) = { },
position: GroupPosition = GroupPosition.Single,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit) = { },
) {
val ui = radiantStyle
val iconShape = RoundedCornerShape(12.dp)
Row(
modifier = modifier
.heightIn(min = 64.dp)
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
// Legacy rows are flat and full bleed with no tile background or grouping. (because i'm lazy)
.then(
if (ui.groupedSettings) {
Modifier
.radiantSurface(shape = position.shape)
.clip(position.shape)
} else Modifier
)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.heightIn(min = 64.dp)
.padding(
horizontal = if (ui.groupedSettings) 16.dp else 32.dp,
vertical = if (ui.groupedSettings) 14.dp else 12.dp,
),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(2f, true)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
if (ui.groupedSettings) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.glow(radius = 10.dp, shape = iconShape, alpha = 0.35f)
.size(36.dp)
.clip(iconShape)
.background(accentBrush, iconShape),
) {
Box(modifier = Modifier.size(20.dp)) {
icon()
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onPrimary,
) {
Box(Modifier.size(20.dp)) { icon() }
}
}
} else {
Box(Modifier.size(20.dp)) { icon() }
}
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
text()
}
Column(
verticalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.weight(1f),
) {
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
text()
}
ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f)
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
) {
secondaryText()
}
}
Spacer(Modifier.weight(0.05f, true))
trailing()
}
}
@@ -1,10 +1,9 @@
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
import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch
@Composable
fun SettingsSwitch(
@@ -13,11 +12,14 @@ fun SettingsSwitch(
disabled: Boolean = false,
icon: @Composable () -> Unit = {},
pref: Boolean,
position: GroupPosition = GroupPosition.Single,
onPrefChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
SettingsItem(
modifier = modifier.clickable(enabled = !disabled) { onPrefChange(!pref) },
modifier = modifier,
position = position,
onClick = if (disabled) null else ({ onPrefChange(!pref) }),
text = { Text(text = label, softWrap = true) },
icon = icon,
secondaryText = {
@@ -26,7 +28,7 @@ fun SettingsSwitch(
}
}
) {
Switch(
RadiantSwitch(
checked = pref,
enabled = !disabled,
onCheckedChange = { onPrefChange(!pref) }
@@ -1,11 +1,12 @@
package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.*
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.components.radiant.RadiantTextField
@Composable
fun SettingsTextField(
@@ -15,15 +16,21 @@ fun SettingsTextField(
error: Boolean = false,
onPrefChange: (String) -> Unit,
) {
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
) {
// Label sits above the field rather than notching into its border.
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
RadiantTextField(
value = pref,
onValueChange = onPrefChange,
enabled = !disabled,
label = { Text(label) },
isError = error,
singleLine = true
)
}
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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 th 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,74 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,115 @@
/**
* 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: (use someone elses code because love not doing things ourselves)
// https://github.com/zt64/compose-pipette/blob/3e9fd958a315dceb142bf30250b4614ecde4e723/sample/src/commonMain/kotlin/dev/zt64/compose/pipette/sample/SampleSlider.kt
package com.meowarex.rlmobile.ui.legacy.components
import 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,44 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,35 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,51 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,53 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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))
}
}
}
}
@@ -0,0 +1,39 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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),
)
}
}
}
@@ -1,4 +1,6 @@
package com.meowarex.rlmobile.ui.components
package com.meowarex.rlmobile.ui.legacy.components
import com.meowarex.rlmobile.ui.components.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@@ -0,0 +1,27 @@
package com.meowarex.rlmobile.ui.legacy.components
import com.meowarex.rlmobile.ui.components.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Tag(text: String) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(6.dp),
) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
@@ -0,0 +1,37 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,32 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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,28 @@
package com.meowarex.rlmobile.ui.legacy.components
import 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)
}
}
}
@@ -0,0 +1,64 @@
package com.meowarex.rlmobile.ui.legacy.components.dialogs
import 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
)
}
@@ -0,0 +1,105 @@
package com.meowarex.rlmobile.ui.legacy.components.dialogs
import 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)
}
@@ -0,0 +1,19 @@
package com.meowarex.rlmobile.ui.legacy.components.settings
import 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.legacy.components.TextDivider
@Composable
fun SettingsHeader(
text: String,
) {
TextDivider(
text = text,
modifier = Modifier.padding(18.dp, 20.dp, 18.dp, 10.dp)
)
}
@@ -0,0 +1,59 @@
package com.meowarex.rlmobile.ui.legacy.components.settings
import 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()
}
}
@@ -0,0 +1,37 @@
package com.meowarex.rlmobile.ui.legacy.components.settings
import 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) }
)
}
}
@@ -0,0 +1,31 @@
package com.meowarex.rlmobile.ui.legacy.components.settings
import 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,114 @@
package com.meowarex.rlmobile.ui.legacy.screens.about
import 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.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.legacy.components.*
import com.meowarex.rlmobile.ui.legacy.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 = "Legacy-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(BuildConfig.PATCHES_REPO_OWNER, "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 -> {
val others = state.contributors.filter { it.username != BuildConfig.PATCHES_REPO_OWNER }
items(others, key = { it.username }) { user ->
ContributorCommitsItem(user)
}
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.meowarex.rlmobile.ui.legacy.screens.about.components
import com.meowarex.rlmobile.ui.screens.about.components.*
import com.meowarex.rlmobile.ui.screens.about.*
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)
)
)
}
}
}
@@ -0,0 +1,365 @@
package com.meowarex.rlmobile.ui.legacy.screens.componentopts
import com.meowarex.rlmobile.ui.screens.componentopts.*
import android.net.Uri
import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.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.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.legacy.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 (patches or apk)
*/
private val componentType: PatchComponent.Type,
/**
* previously selected custom component that should be pre selected on this screen (i dont think this has ever worked)
*/
private val default: PatchComponent?,
) : ScreenWithResult<PatchComponent?>(), Parcelable {
@IgnoredOnParcel
override val key = "Legacy-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,
onImportFromUri = { uri -> model.importFromUri(uri, componentType) },
releasesExpanded = model.releasesExpanded,
releasesState = model.releasesState,
onToggleReleases = { model.toggleReleasesExpanded(componentType) },
onImportRelease = { release -> model.importFromRelease(release, componentType) },
importingReleaseTag = model.importingReleaseTag,
onBackPressed = { navigator.back(null) },
)
}
}
@Composable
fun ComponentOptionsScreenContent(
componentType: PatchComponent.Type,
components: ImmutableList<PatchComponent>,
selected: PatchComponent?,
onSelectComponent: (PatchComponent?) -> Unit,
onDeleteComponent: (PatchComponent) -> Unit,
onImportFromUri: (Uri) -> Unit,
releasesExpanded: Boolean,
releasesState: ComponentOptionsModel.ReleasesState,
onToggleReleases: () -> Unit,
onImportRelease: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit,
importingReleaseTag: String?,
onBackPressed: () -> Unit,
) {
val mime = when (componentType) {
PatchComponent.Type.TidalApk -> "application/vnd.android.package-archive"
PatchComponent.Type.Patches -> "application/zip"
}
val filePicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(onImportFromUri) }
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,
)
}
}
item(key = "RELEASES_ACCORDION") {
ReleasesAccordion(
expanded = releasesExpanded,
state = releasesState,
importingTag = importingReleaseTag,
onToggle = onToggleReleases,
onImport = onImportRelease,
)
}
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(key = "BROWSE") {
BrowseImportCard(onClick = { filePicker.launch(arrayOf(mime)) })
}
item("EXIT_BTN") {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
) {
FilledTonalButton(
onClick = onBackPressed,
) {
Text(stringResource(R.string.action_confirm))
}
}
}
}
}
}
@Composable
private fun ReleasesAccordion(
expanded: Boolean,
state: ComponentOptionsModel.ReleasesState,
importingTag: String?,
onToggle: () -> Unit,
onImport: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.componentopts_releases_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.componentopts_releases_desc),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = 0.65f),
)
}
Icon(
painter = painterResource(
if (expanded) R.drawable.ic_arrow_up_small else R.drawable.ic_arrow_down_small
),
contentDescription = null,
tint = LocalContentColor.current.copy(alpha = 0.7f),
)
}
if (expanded) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
)
ReleasesContent(
state = state,
importingTag = importingTag,
onImport = onImport,
)
}
}
}
}
@Composable
private fun ReleasesContent(
state: ComponentOptionsModel.ReleasesState,
importingTag: String?,
onImport: (com.meowarex.rlmobile.network.models.GithubRelease) -> Unit,
) {
when (state) {
is ComponentOptionsModel.ReleasesState.Idle,
ComponentOptionsModel.ReleasesState.Loading -> Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
) { CircularProgressIndicator(modifier = Modifier.size(24.dp)) }
ComponentOptionsModel.ReleasesState.Failed -> Text(
text = stringResource(R.string.network_load_fail),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
)
is ComponentOptionsModel.ReleasesState.Loaded -> {
if (state.releases.isEmpty()) {
Text(
text = stringResource(R.string.componentopts_releases_empty),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = 0.65f),
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
)
} else {
Column {
state.releases.forEachIndexed { index, release ->
ReleaseRow(
release = release,
importing = importingTag == release.tagName,
anyImporting = importingTag != null,
onImport = { onImport(release) },
)
if (index != state.releases.lastIndex) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f),
)
}
}
}
}
}
}
}
@Composable
private fun ReleaseRow(
release: com.meowarex.rlmobile.network.models.GithubRelease,
importing: Boolean,
anyImporting: Boolean,
onImport: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = release.tagName,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = release.name ?: release.tagName,
style = MaterialTheme.typography.bodySmall,
color = LocalContentColor.current.copy(alpha = 0.55f),
)
}
FilledTonalButton(
onClick = onImport,
enabled = !anyImporting,
) {
if (importing) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp),
)
} else {
Text(stringResource(R.string.action_install))
}
}
}
}
@Composable
private fun BrowseImportCard(onClick: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.componentopts_browse_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.componentopts_browse_desc),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current.copy(alpha = 0.65f),
)
}
}
}
}
@@ -0,0 +1,37 @@
package com.meowarex.rlmobile.ui.legacy.screens.componentopts.components
import com.meowarex.rlmobile.ui.screens.componentopts.components.*
import com.meowarex.rlmobile.ui.screens.componentopts.*
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.legacy.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),
)
}
},
)
}
@@ -0,0 +1,118 @@
package com.meowarex.rlmobile.ui.legacy.screens.componentopts.components
import com.meowarex.rlmobile.ui.screens.componentopts.components.*
import com.meowarex.rlmobile.ui.screens.componentopts.*
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,278 @@
package com.meowarex.rlmobile.ui.legacy.screens.home
import com.meowarex.rlmobile.ui.screens.home.*
import android.os.Parcelable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.activity.ComponentActivity
import androidx.compose.ui.platform.LocalContext
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.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.paging.compose.collectAsLazyPagingItems
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.legacy.components.SegmentedButton
import com.meowarex.rlmobile.ui.legacy.components.Tag
import com.meowarex.rlmobile.ui.legacy.screens.about.AboutScreen
import com.meowarex.rlmobile.ui.legacy.screens.home.components.CommitList
import com.meowarex.rlmobile.ui.legacy.screens.patchopts.PatchOptionsScreen
import com.meowarex.rlmobile.ui.legacy.screens.settings.SettingsScreen
import com.meowarex.rlmobile.ui.legacy.widgets.managerupdate.ManagerUpdateDialog
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterViewModel
import com.meowarex.rlmobile.util.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.androidx.compose.koinViewModel
@Parcelize
class HomeScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Legacy-Home"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val scope = rememberCoroutineScope()
val model = koinScreenModel<HomeModel>()
val activity = LocalContext.current as ComponentActivity
val updater = koinViewModel<UpdaterViewModel>(viewModelStoreOwner = activity)
val managerUpdateAvailable = updater.targetVersion != null
LifecycleResumeEffect(Unit) {
model.refresh(delay = true)
onPauseOrDispose {}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.navigation_home)) },
actions = {
IconButton(onClick = { model.refresh() }) {
Icon(
painterResource(R.drawable.ic_refresh),
contentDescription = stringResource(R.string.navigation_refresh),
)
}
if (managerUpdateAvailable) {
IconButton(onClick = updater::reopenDialog) {
Icon(
painterResource(R.drawable.ic_update),
contentDescription = stringResource(R.string.action_update),
)
}
}
IconButton(onClick = { navigator.push(AboutScreen()) }) {
Icon(
painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator.push(SettingsScreen()) }) {
Icon(
painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings),
)
}
},
)
},
) { pv ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(pv)
.padding(16.dp)
.fillMaxSize(),
) {
val state = model.state
when (state) {
HomeState.Loading -> Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) { CircularProgressIndicator() }
is HomeState.Loaded -> HomeContent(
state = state,
commits = model.commits,
managerUpdateAvailable = managerUpdateAvailable,
onInstall = { navigator.pushOnce(PatchOptionsScreen()) },
onRepatch = {
scope.launchIO {
val options = model.createRepatchOptions() ?: return@launchIO
val screen = PatchOptionsScreen(prefilledOptions = options)
mainThread { navigator.push(screen) }
}
},
onLaunch = model::launchInstall,
onInfo = model::openCurrentAppInfo,
)
}
model.managerUpdateDeltas?.let { deltas ->
ManagerUpdateDialog(
deltas = deltas,
onDismiss = model::dismissManagerUpdate,
)
}
}
}
}
}
@Composable
private fun ColumnScope.HomeContent(
state: HomeState.Loaded,
commits: kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<com.meowarex.rlmobile.network.models.GithubCommit>>,
managerUpdateAvailable: Boolean,
onInstall: () -> Unit,
onRepatch: () -> Unit,
onLaunch: () -> Unit,
onInfo: () -> Unit,
) {
val install = state.install
val currentVersionName = (install?.version as? com.meowarex.rlmobile.ui.util.TidalVersion.Existing)
?.let { "v${it.name} (build ${it.code})" }
val latestVersionName = state.latestTidalVersionCode?.let { "build $it" }
val fallbackPainter = if (install?.icon == null) {
// R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode.
val context = LocalContext.current
remember {
val drawable = ContextCompat.getDrawable(context, R.mipmap.ic_launcher)
drawable?.toBitmap()?.asImageBitmap()?.let(::BitmapPainter)
}
} else null
val iconPainter = install?.icon ?: fallbackPainter
if (iconPainter != null) {
Image(
painter = iconPainter,
contentDescription = null,
modifier = Modifier
.size(60.dp)
.clip(CircleShape),
)
}
Text(
text = install?.name ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
AnimatedVisibility(visible = currentVersionName != null) {
Text(
text = "Current: ${currentVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
AnimatedVisibility(visible = latestVersionName != null) {
Text(
text = "Latest: ${latestVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
}
val patchesBehind = install != null && install.patchesUpToDate == false
val tidalBehind = install != null && install.tidalUpToDate == false
AnimatedVisibility(visible = patchesBehind || tidalBehind) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (patchesBehind) Tag(text = "New Patches!")
if (tidalBehind) Tag(text = "TIDAL Update!")
}
}
val blockedByManagerUpdate = managerUpdateAvailable && (patchesBehind || tidalBehind)
val canLocalRepatch = install != null && state.offlineRepatchReady
val onlineEnabled = state.latestTidalVersionCode != null || install != null
val buttonEnabled = !blockedByManagerUpdate && (if (state.offline) canLocalRepatch else onlineEnabled)
Button(
onClick = if (install == null) onInstall else onRepatch,
enabled = buttonEnabled,
modifier = Modifier.fillMaxWidth(),
) {
val label = when {
blockedByManagerUpdate -> "Manager Update Required"
state.offline && install != null -> "Local Repatch"
state.offline -> "No Network"
install == null && state.latestTidalVersionCode == null -> "Loading…"
install == null -> "Install"
patchesBehind && tidalBehind -> "Update Patches & TIDAL"
patchesBehind -> "Update Patches"
tidalBehind -> "Update TIDAL"
else -> "Repatch"
}
Text(
text = label,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.basicMarquee()
.fillMaxWidth(),
)
}
if (state.offline && install != null) {
Text(
text = "No Network Connection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
)
}
AnimatedVisibility(visible = install != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(RoundedCornerShape(16.dp)),
) {
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onLaunch,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onInfo,
)
}
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
CommitList(commits = commits.collectAsLazyPagingItems())
}
}
@@ -0,0 +1,118 @@
package com.meowarex.rlmobile.ui.legacy.screens.home.components
import com.meowarex.rlmobile.ui.screens.home.components.*
import com.meowarex.rlmobile.ui.screens.home.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.clip
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import coil3.compose.AsyncImage
import com.meowarex.rlmobile.network.models.GithubCommit
import java.text.SimpleDateFormat
import java.time.Instant
import java.util.Date
@Composable
fun CommitList(commits: LazyPagingItems<GithubCommit>) {
val loading = commits.loadState.refresh is LoadState.Loading || commits.loadState.append is LoadState.Loading
val failed = commits.loadState.refresh is LoadState.Error || commits.loadState.append is LoadState.Error
LazyColumn {
items(
count = commits.itemCount,
key = commits.itemKey { it.sha },
) { index ->
val commit = commits[index] ?: return@items
CommitRow(commit)
if (index < commits.itemCount - 1) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
if (loading) item {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
CircularProgressIndicator(strokeWidth = 3.dp, modifier = Modifier.size(30.dp))
}
}
if (failed) item {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text("Failed to load commits", style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center)
Button(onClick = { commits.retry() }) { Text("Retry") }
}
}
}
}
@Composable
private fun CommitRow(commit: GithubCommit) {
val uriHandler = LocalUriHandler.current
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(commit.htmlUrl) }
.padding(16.dp),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = commit.author?.avatarUrl ?: "https://github.com/ghost.png",
contentDescription = commit.author?.login ?: "ghost",
modifier = Modifier.size(20.dp).clip(CircleShape),
)
Text(
text = commit.author?.login ?: commit.commit.author.name,
style = MaterialTheme.typography.labelMedium,
)
Text("", style = MaterialTheme.typography.labelLarge)
Text(
text = commit.sha.take(7),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
)
Text(
text = runCatching {
SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT)
.format(Date.from(Instant.parse(commit.commit.author.date)))
}.getOrDefault(""),
style = MaterialTheme.typography.labelMedium,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.End,
modifier = Modifier.weight(1f),
)
}
Text(
text = commit.commit.message.lineSequence().first(),
style = MaterialTheme.typography.labelLarge,
)
}
}
@@ -0,0 +1,127 @@
package com.meowarex.rlmobile.ui.legacy.screens.log
import 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.legacy.components.Label
import com.meowarex.rlmobile.ui.legacy.screens.log.components.LogAppBar
import com.meowarex.rlmobile.ui.legacy.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() = "Legacy-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,36 @@
package com.meowarex.rlmobile.ui.legacy.screens.log.components
import com.meowarex.rlmobile.ui.screens.log.components.*
import com.meowarex.rlmobile.ui.screens.log.*
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.legacy.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),
)
}
},
)
}
@@ -0,0 +1,45 @@
package com.meowarex.rlmobile.ui.legacy.screens.log.components
import com.meowarex.rlmobile.ui.screens.log.components.*
import com.meowarex.rlmobile.ui.screens.log.*
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,304 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching
import 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.legacy.components.MainActionButton
import com.meowarex.rlmobile.ui.legacy.components.Wakelock
import com.meowarex.rlmobile.ui.legacy.components.dialogs.InstallerAbortDialog
import com.meowarex.rlmobile.ui.legacy.screens.log.LogScreen
import com.meowarex.rlmobile.ui.legacy.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.legacy.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. (allot of glorp)
* This may originate from [PatchOptionsScreen] or from an existing installation (to update it)
*/
private val options: PatchOptions,
) : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Legacy-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 (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),
)
}
}
}
@@ -0,0 +1,53 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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.legacy.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)
)
}
}
}
@@ -0,0 +1,27 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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),
)
}
}
)
}
@@ -0,0 +1,118 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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)
}
}
}
}
}
@@ -0,0 +1,52 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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,
)
}
}
@@ -0,0 +1,91 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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 because "Play has no limits"
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)
)
}
}
}
@@ -0,0 +1,61 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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,
)
}
}
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.legacy.screens.patching.components
import com.meowarex.rlmobile.ui.screens.patching.components.*
import com.meowarex.rlmobile.ui.screens.patching.*
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,
)
}
}
@@ -0,0 +1,313 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts
import 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.legacy.components.*
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.legacy.screens.componentopts.ComponentOptionsScreen
import com.meowarex.rlmobile.ui.legacy.screens.patching.PatchingScreen
import androidx.compose.runtime.saveable.rememberSaveable
import com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.PackageNameStateLabel
import com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.PackageNameUnlockDialog
import com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.PatchOptionsAppBar
import com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.PatchSelectionAccordion
import com.meowarex.rlmobile.ui.legacy.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 = "Legacy-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,
bypassIncompatible = model.bypassIncompatible,
setBypassIncompatible = model::changeBypassIncompatible,
appName = model.appName,
appNameIsError = model.appNameIsError,
setAppName = model::changeAppName,
packageName = model.packageName,
packageNameState = model.packageNameState,
setPackageName = model::changePackageName,
packageNameLocked = model.packageNameLocked,
onUnlockPackageName = model::unlockPackageName,
onLockPackageName = model::lockPackageName,
customTidalApk = model.customTidalApk,
customPatches = model.customPatches,
onSelectCustomTidalApk = {
model.selectCustomTidalApk(navigator) { default ->
ComponentOptionsScreen(default = default, componentType = PatchComponent.Type.TidalApk)
}
},
onSelectCustomPatches = {
model.selectCustomPatches(navigator) { default ->
ComponentOptionsScreen(default = default, componentType = PatchComponent.Type.Patches)
}
},
specs = model.specs,
isPatchEnabled = model::isPatchEnabled,
onTogglePatch = model::setPatchEnabled,
patchLockState = model::lockState,
variantIndex = model::variantIndex,
onSelectVariant = model::selectVariant,
optionState = model.optionState,
isConfigValid = model.isConfigValid,
onInstall = {
navigator.push(PatchingScreen(model.generateConfig()))
},
)
}
}
@Composable
fun PatchOptionsScreenContent(
isUpdate: Boolean,
isDevMode: Boolean,
debuggable: Boolean,
setDebuggable: (Boolean) -> Unit,
bypassIncompatible: Boolean,
setBypassIncompatible: (Boolean) -> Unit,
appName: String,
appNameIsError: Boolean,
setAppName: (String) -> Unit,
packageName: String,
packageNameState: PackageNameState,
setPackageName: (String) -> Unit,
packageNameLocked: Boolean,
onUnlockPackageName: () -> Unit,
onLockPackageName: () -> Unit,
customTidalApk: PatchComponent?,
onSelectCustomTidalApk: () -> Unit,
customPatches: PatchComponent?,
onSelectCustomPatches: () -> Unit,
specs: List<PatchSpec>,
isPatchEnabled: (PatchSpec) -> Boolean,
onTogglePatch: (PatchSpec, Boolean) -> Unit,
patchLockState: (PatchSpec) -> PatchLock,
variantIndex: (PatchSpec) -> Int,
onSelectVariant: (PatchSpec, Int) -> Unit,
optionState: PatchOptionState,
isConfigValid: Boolean,
onInstall: () -> Unit,
) {
var showPkgUnlockDialog by rememberSaveable { mutableStateOf(false) }
if (showPkgUnlockDialog) {
PackageNameUnlockDialog(
// With "Bypass Incompatible" on path-gated patches are force-applied (not disabled)
blockedTitles = if (bypassIncompatible) emptyList() else specs.filter { it.pathLocked }.map { it.title },
onConfirm = {
showPkgUnlockDialog = false
onUnlockPackageName()
},
onDismiss = { showPkgUnlockDialog = false },
)
}
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) },
locked = packageNameLocked,
onLockClick = {
if (packageNameLocked) showPkgUnlockDialog = true else onLockPackageName()
},
) {
PackageNameStateLabel(
state = packageNameState,
modifier = Modifier.padding(start = 4.dp),
)
}
}
val (integrations, patches) = remember(specs) { specs.partition { it.isIntegration } }
if (integrations.isNotEmpty()) {
PatchSelectionAccordion(
iconRes = R.drawable.ic_extension,
titleRes = R.string.patchopts_integrations_title,
descriptionRes = R.string.patchopts_integrations_desc,
specs = integrations,
enabledCount = integrations.count(isPatchEnabled),
totalCount = integrations.size,
isEnabled = isPatchEnabled,
onToggle = onTogglePatch,
lockState = patchLockState,
variantIndex = variantIndex,
onSelectVariant = onSelectVariant,
optionState = optionState,
modifier = Modifier.padding(top = 4.dp),
)
}
PatchSelectionAccordion(
iconRes = R.drawable.ic_healing,
titleRes = R.string.patchopts_patches_title,
descriptionRes = R.string.patchopts_patches_desc,
specs = patches,
enabledCount = patches.count(isPatchEnabled),
totalCount = patches.size,
isEnabled = isPatchEnabled,
onToggle = onTogglePatch,
lockState = patchLockState,
variantIndex = variantIndex,
onSelectVariant = onSelectVariant,
optionState = optionState,
modifier = if (integrations.isEmpty()) Modifier.padding(top = 4.dp) else Modifier,
)
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_music_note),
name = stringResource(R.string.patchopts_custom_tidal_apk_title),
description = stringResource(R.string.patchopts_custom_tidal_apk_desc),
modifier = Modifier.clickable(onClick = onSelectCustomTidalApk),
) {
FilledTonalButton(onClick = onSelectCustomTidalApk) {
Text(
text = customTidalApk?.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)
)
}
}
SwitchPatchOption(
icon = painterResource(R.drawable.ic_lock_open),
name = stringResource(R.string.patchopts_bypass_incompatible_title),
description = stringResource(R.string.patchopts_bypass_incompatible_desc),
value = bypassIncompatible,
onValueChange = setBypassIncompatible,
)
}
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))
}
}
}
}
@@ -0,0 +1,68 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
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),
)
}
}
}
@@ -0,0 +1,91 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
/**
* Confirms unlocking the package name field
* (warning that a custom name blocks the listed patches/integrations from working)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PackageNameUnlockDialog(
blockedTitles: List<String>,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(R.string.patchopts_pkgname_warning_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(
stringResource(R.string.patchopts_pkgname_warning_msg, PatchOptions.Default.packageName),
),
style = MaterialTheme.typography.bodyLarge,
)
if (blockedTitles.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(start = 4.dp),
) {
for (title in blockedTitles) {
Text(
text = "$title",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
Text(
text = stringResource(R.string.patchopts_pkgname_warning_permanent),
style = MaterialTheme.typography.bodyLarge,
)
Box(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.align(Alignment.CenterEnd)) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.action_continue))
}
}
}
}
}
}
}
@@ -0,0 +1,688 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
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.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.legacy.components.ResetToDefaultButton
import com.meowarex.rlmobile.ui.screens.patchopts.OptionLock
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
import com.meowarex.rlmobile.ui.screens.patchopts.hiddenForVariant
import com.meowarex.rlmobile.ui.screens.patchopts.optionLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatchAdvancedOptionsSheet(
patch: PatchSpec,
state: PatchOptionState,
selectedVariant: Int,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var lockDialog by remember { mutableStateOf<OptionLock?>(null) }
fun dismiss() {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) onDismiss()
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.patchopts_advanced_sheet_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = patch.title,
style = MaterialTheme.typography.headlineSmall,
)
}
ResetToDefaultButton(
enabled = state.isModified(patch),
onClick = { state.reset(patch) },
)
}
HorizontalDivider()
val parentKeys = patch.advancedOptions
.filterIsInstance<OptionSpec.Toggle>()
.filter { !it.inline }
.map { it.key }
.toSet()
val sheetOptions = patch.advancedOptions.filter { option ->
when {
option is OptionSpec.Toggle && option.inline -> false
option is OptionSpec.Toggle && option.requiresOption in parentKeys -> false
// Options belonging to another variant are hidden, not greyed out.
option.hiddenForVariant(selectedVariant) -> false
else -> true
}
}
for (option in sheetOptions) key(option.key) {
val lock = patch.optionLock(option, selectedVariant) { state.toggle(patch, it) }
when (option) {
is OptionSpec.Toggle -> {
val toggleOn = state.toggle(patch, option)
val gatedChoices = patch.advancedOptions.filterIsInstance<OptionSpec.Choice>()
.filter { it.requiresOption == option.key }
val gatedToggles = patch.advancedOptions.filterIsInstance<OptionSpec.Toggle>()
.filter { it.requiresOption == option.key && !it.inline }
val hasSubOptions = gatedChoices.isNotEmpty() || gatedToggles.isNotEmpty()
val carded = hasSubOptions && toggleOn
Column(
modifier = if (carded) {
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.07f))
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f),
shape = MaterialTheme.shapes.medium,
)
.padding(horizontal = 12.dp, vertical = 4.dp)
} else {
Modifier.fillMaxWidth()
},
) {
ToggleOptionRow(
title = option.title,
description = option.description,
checked = toggleOn,
onCheckedChange = { state.setToggle(patch, option, it) },
locked = lock != OptionLock.Free,
onLockedTap = { lockDialog = lock },
modifier = if (hasSubOptions) Modifier.padding(vertical = 6.dp) else Modifier,
)
if (hasSubOptions) {
AnimatedVisibility(visible = toggleOn) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
gatedChoices.forEach { choice ->
key(choice.key) {
ChoiceOptionRow(
title = choice.title,
entries = choice.entries,
selectedIndex = state.choice(patch, choice),
onSelect = { state.setChoice(patch, choice, it) },
)
}
}
gatedToggles.forEach { sub ->
key(sub.key) {
val subLock = patch.optionLock(sub, selectedVariant) { state.toggle(patch, it) }
ToggleOptionRow(
title = sub.title,
description = sub.description,
checked = state.toggle(patch, sub),
onCheckedChange = { state.setToggle(patch, sub, it) },
locked = subLock != OptionLock.Free,
onLockedTap = { lockDialog = subLock },
)
}
}
}
}
}
}
}
is OptionSpec.Slider -> SliderOptionRow(
title = option.title,
description = option.description,
value = state.slider(patch, option),
valueLabel = formatSliderValue(option, state.slider(patch, option)),
valueRange = option.valueRange,
steps = option.steps,
onValueChange = { state.setSlider(patch, option, it) },
)
is OptionSpec.Choice ->
if (option.requiresOption == null) {
ChoiceOptionRow(
title = option.title,
entries = option.entries,
selectedIndex = state.choice(patch, option),
onSelect = { state.setChoice(patch, option, it) },
)
}
is OptionSpec.Color -> ColorOptionRow(
title = option.title,
color = state.color(patch, option),
onColorChange = { state.setColor(patch, option, it) },
)
}
}
FilledTonalButton(
onClick = { dismiss() },
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp),
) {
Text(stringResource(R.string.action_done))
}
}
}
lockDialog?.let { lock ->
OptionLockDialog(lock = lock, onDismiss = { lockDialog = null })
}
}
@Composable
private fun ToggleOptionRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
locked: Boolean = false,
onLockedTap: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
) { if (locked) onLockedTap() else onCheckedChange(!checked) }
.alpha(if (locked) 0.45f else 1f),
) {
OptionText(
title = title,
description = description,
modifier = Modifier.weight(1f),
)
Box {
Switch(
checked = checked,
enabled = !locked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
if (locked) {
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = remember(::MutableInteractionSource),
indication = null,
role = Role.Switch,
) { onLockedTap() }
)
}
}
}
}
@Composable
private fun SliderOptionRow(
title: String,
description: String,
value: Float,
valueLabel: String,
valueRange: ClosedFloatingPointRange<Float>,
steps: Int,
onValueChange: (Float) -> Unit,
) {
// Whether the slider snaps to the step dots
var snap by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Text(
text = valueLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
if (steps > 0) {
IconButton(
onClick = {
snap = !snap
if (snap) onValueChange(snapToNearestStep(value, valueRange, steps))
},
modifier = Modifier.size(32.dp),
) {
Icon(
painter = painterResource(
if (snap) R.drawable.ic_lock else R.drawable.ic_lock_open,
),
contentDescription = stringResource(R.string.patchopts_toggle_snap),
tint = if (snap) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
// Locked -> native stepped slider (dots + snapping)
// Unlocked -> plain continuous slider with the trailing stop-indicator dot removed
if (snap) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
steps = steps,
)
} else {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
track = { SliderDefaults.Track(sliderState = it, drawStopIndicator = null) },
)
}
}
}
private fun snapToNearestStep(
value: Float,
range: ClosedFloatingPointRange<Float>,
steps: Int,
): Float {
if (steps <= 0) return value
val stepSize = (range.endInclusive - range.start) / (steps + 1)
if (stepSize <= 0f) return value
val snapped = range.start + Math.round((value - range.start) / stepSize) * stepSize
return snapped.coerceIn(range.start, range.endInclusive)
}
@Composable
private fun ChoiceOptionRow(
entries: List<String>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
title: String = "",
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (title.isNotEmpty()) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
if (entries.size > 3) {
var expanded by rememberSaveable { mutableStateOf(false) }
val selectedLabel = entries.getOrNull(selectedIndex).orEmpty()
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = selectedLabel,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(),
) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onSelect(index)
expanded = false
},
)
}
}
}
return@Column
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = index == selectedIndex,
onClick = { onSelect(index) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = entries.size),
icon = {},
label = {
Text(
text = entry,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
}
}
}
@Composable
private fun ColorOptionRow(
title: String,
color: Int,
onColorChange: (Int) -> Unit,
) {
var showPicker by rememberSaveable { mutableStateOf(false) }
val selectedColor = color.withOpaqueAlpha()
val materialYouColor = MaterialTheme.colorScheme.primary.toArgb().withOpaqueAlpha()
val presets = listOf(
stringResource(R.string.patch_waze_color_waze_default),
stringResource(R.string.patch_waze_color_tidal_cyan),
stringResource(R.string.patch_waze_color_material_you),
)
val presetColors = listOf(WAZE_DEFAULT, TIDAL_CYAN, materialYouColor)
val selectedPreset = presetColors.indexOf(selectedColor)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable { showPicker = true }
.padding(vertical = 4.dp),
) {
ColorSwatch(color = selectedColor)
Text(
text = selectedColor.toRgbHex(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
presets.forEachIndexed { index, label ->
SegmentedButton(
selected = index == selectedPreset,
onClick = { onColorChange(presetColors[index]) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = presets.size),
icon = {},
label = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
}
}
if (showPicker) {
ColorPickerDialog(
initialColor = selectedColor,
onDismiss = { showPicker = false },
onColorSelected = {
onColorChange(it)
showPicker = false
},
)
}
}
@Composable
private fun ColorSwatch(color: Int, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(32.dp)
.clip(CircleShape)
.background(Color(color))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape),
)
}
@Composable
private fun ColorPickerDialog(
initialColor: Int,
onDismiss: () -> Unit,
onColorSelected: (Int) -> Unit,
) {
var color by rememberSaveable(initialColor) { mutableIntStateOf(initialColor.withOpaqueAlpha()) }
var hex by rememberSaveable(initialColor) { mutableStateOf(color.toRgbHex()) }
val parsedHex = parseRgbHex(hex)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.patch_color_picker_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ColorSwatch(color = parsedHex ?: color, modifier = Modifier.size(40.dp))
OutlinedTextField(
value = hex,
onValueChange = { value ->
hex = value
parseRgbHex(value)?.let { color = it }
},
label = { Text(stringResource(R.string.patch_color_picker_hex)) },
singleLine = true,
isError = parsedHex == null,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
keyboardType = KeyboardType.Ascii,
),
supportingText = if (parsedHex == null) {
{ Text(stringResource(R.string.patch_color_picker_hex_error)) }
} else null,
modifier = Modifier.weight(1f),
)
}
}
},
confirmButton = {
TextButton(
enabled = parsedHex != null,
onClick = { parsedHex?.let(onColorSelected) },
) {
Text(stringResource(R.string.action_done))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun OptionText(
title: String,
description: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
}
private fun formatSliderValue(option: OptionSpec.Slider, value: Float): String {
val rounded = value.roundToInt()
val unit = option.unit
return when {
option.displayAsPercent -> "$rounded%"
unit != null -> "$rounded $unit"
else -> rounded.toString()
}
}
private const val OPAQUE_ALPHA = -0x1000000
private const val WAZE_DEFAULT = -16747037 // #ff0075e3
private const val TIDAL_CYAN = -14549268 // #ff21feec
private fun Int.withOpaqueAlpha(): Int = (this and 0x00FFFFFF) or OPAQUE_ALPHA
private fun Int.toRgbHex(): String = "#" +
(this and 0x00FFFFFF).toString(16).uppercase().padStart(6, '0')
private fun parseRgbHex(value: String): Int? {
val clean = value.trim()
.removePrefix("#")
.removePrefix("0x")
.removePrefix("0X")
if (clean.length != 6 && clean.length != 8) return null
val parsed = clean.toLongOrNull(16) ?: return null
val rgb = if (clean.length == 8) parsed and 0x00FFFFFFL else parsed
return (0xFF000000L or rgb).toInt()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OptionLockDialog(lock: OptionLock, onDismiss: () -> Unit) {
val message = when (lock) {
is OptionLock.NeedsVariant -> stringResource(R.string.patch_opt_lock_needs_variant, lock.title)
is OptionLock.NeedsOption -> stringResource(R.string.patch_opt_lock_needs_option, lock.title)
OptionLock.Free -> return
}
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(R.string.patch_opt_lock_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(message),
style = MaterialTheme.typography.titleMedium,
)
Box(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterEnd),
) {
Text(stringResource(R.string.action_got_it))
}
}
}
}
}
}
@@ -0,0 +1,33 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
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.legacy.components.BackButton
import com.meowarex.rlmobile.ui.legacy.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)
)
}
}
)
}
@@ -0,0 +1,433 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.*
import androidx.compose.runtime.saveable.rememberSaveable
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.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.screens.patchopts.OptionSpec
import com.meowarex.rlmobile.ui.screens.patchopts.PatchLock
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionState
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
import com.meowarex.rlmobile.ui.screens.patchopts.effectiveVariants
import com.meowarex.rlmobile.ui.screens.patchopts.resolveVariantIndex
private data class LockInfo(val patch: PatchSpec, val lock: PatchLock)
@Composable
fun PatchSelectionAccordion(
@DrawableRes iconRes: Int,
@StringRes titleRes: Int,
@StringRes descriptionRes: Int,
specs: List<PatchSpec>,
enabledCount: Int,
totalCount: Int,
isEnabled: (PatchSpec) -> Boolean,
onToggle: (PatchSpec, Boolean) -> Unit,
lockState: (PatchSpec) -> PatchLock,
variantIndex: (PatchSpec) -> Int,
onSelectVariant: (PatchSpec, Int) -> Unit,
optionState: PatchOptionState,
modifier: Modifier = Modifier,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
label = "patch-accordion-arrow",
)
var lockInfo by remember { mutableStateOf<LockInfo?>(null) }
var advancedFor by remember { mutableStateOf<PatchSpec?>(null) }
Column(
modifier = modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLow),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(role = Role.Button) { expanded = !expanded }
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
Icon(
painter = painterResource(iconRes),
contentDescription = null,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(
R.string.patchopts_patches_summary,
enabledCount,
totalCount,
),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
Icon(
painter = painterResource(R.drawable.ic_arrow_down_small),
contentDescription = stringResource(
if (expanded) R.string.action_collapse else R.string.action_expand,
),
modifier = Modifier.rotate(rotation),
)
}
AnimatedVisibility(visible = expanded) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background.copy(0.4f))
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.alpha(.7f)
.padding(bottom = 8.dp),
)
for (patch in specs) key(patch.id) {
val checked = isEnabled(patch)
val lock = lockState(patch)
val inlineToggles = patch.advancedOptions
.filterIsInstance<OptionSpec.Toggle>()
.filter { it.inline }
val hasSheetOptions = patch.advancedOptions.any { it !is OptionSpec.Toggle || !it.inline }
val hasSettings = patch.variants.isNotEmpty() || inlineToggles.isNotEmpty() || hasSheetOptions
// When a patch is enabled and has settings show a carded view of the settings
val carded = checked && hasSettings
Column(
modifier = if (carded) {
Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.07f))
.border(
width = 1.5.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f),
shape = MaterialTheme.shapes.medium,
)
.padding(horizontal = 12.dp, vertical = 4.dp)
} else {
Modifier.fillMaxWidth()
},
) {
PatchSwitchRow(
title = patch.title,
description = patch.description,
checked = checked,
lock = lock,
onCheckedChange = { onToggle(patch, it) },
onLockedTap = { lockInfo = LockInfo(patch, lock) },
)
if (hasSettings) {
AnimatedVisibility(visible = checked) {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
val resolvedVariant =
if (patch.variants.isNotEmpty())
patch.resolveVariantIndex(variantIndex(patch)) {
optionState.toggle(patch, it)
}
else -1
if (patch.variants.isNotEmpty()) {
val effective = patch.effectiveVariants { optionState.toggle(patch, it) }
PatchVariantSelector(
variants = effective,
selectedIndex = effective
.indexOfFirst { it.originalIndex == resolvedVariant }
.coerceAtLeast(0),
onSelect = { pos ->
effective.getOrNull(pos)?.let { onSelectVariant(patch, it.originalIndex) }
},
)
}
for (option in inlineToggles) key(option.key) {
val show = option.requiresVariant == null ||
option.requiresVariant == resolvedVariant
if (show) {
InlineToggleRow(
title = option.title,
description = option.description,
checked = optionState.toggle(patch, option),
onCheckedChange = { optionState.setToggle(patch, option, it) },
)
}
}
if (hasSheetOptions) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
AdvancedOptionsButton(
modified = optionState.isModified(patch),
onClick = { advancedFor = patch },
)
}
}
}
}
}
}
}
}
}
}
lockInfo?.let { info ->
PatchLockDialog(
thisPatch = info.patch,
lock = info.lock,
onDismiss = { lockInfo = null },
)
}
advancedFor?.let { patch ->
PatchAdvancedOptionsSheet(
patch = patch,
state = optionState,
selectedVariant = patch.resolveVariantIndex(variantIndex(patch)) { optionState.toggle(patch, it) },
onDismiss = { advancedFor = null },
)
}
}
@Composable
private fun AdvancedOptionsButton(
modified: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
FilledTonalButton(
onClick = onClick,
modifier = modifier,
) {
Icon(
painter = painterResource(R.drawable.ic_tune),
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.patchopts_advanced_button))
if (modified) {
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
}
}
@Composable
private fun InlineToggleRow(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
) { onCheckedChange(!checked) }
.padding(vertical = 4.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
}
}
@Composable
private fun PatchSwitchRow(
title: String,
description: String,
checked: Boolean,
lock: PatchLock,
onCheckedChange: (Boolean) -> Unit,
onLockedTap: () -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
val isLocked = lock !is PatchLock.Free
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = interactionSource,
indication = null,
role = Role.Switch,
) {
if (isLocked) onLockedTap() else onCheckedChange(!checked)
}
.alpha(if (isLocked) 0.45f else 1f)
.padding(vertical = 6.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.weight(1f),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(.7f),
)
}
Box {
Switch(
checked = checked,
enabled = !isLocked,
onCheckedChange = onCheckedChange,
interactionSource = interactionSource,
)
if (isLocked) {
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = remember(::MutableInteractionSource),
indication = null,
role = Role.Switch,
) { onLockedTap() }
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PatchLockDialog(
thisPatch: PatchSpec,
lock: PatchLock,
onDismiss: () -> Unit,
) {
val (titleRes, msgRes, blockerTitle) = when (lock) {
is PatchLock.LockedOn -> Triple(
R.string.patch_lock_required_title,
R.string.patch_lock_required_msg,
lock.by.title,
)
is PatchLock.LockedOff -> Triple(
R.string.patch_lock_blocked_title,
R.string.patch_lock_blocked_msg,
lock.by.title,
)
PatchLock.RequiresDefaultPackage -> Triple(
R.string.patch_lock_pkgname_title,
R.string.patch_lock_pkgname_msg,
PatchOptions.Default.packageName,
)
PatchLock.Free -> return
}
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(stringResource(msgRes, blockerTitle)),
style = MaterialTheme.typography.bodyLarge,
)
Box(modifier = Modifier.fillMaxWidth()) {
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterEnd),
) {
Text(stringResource(R.string.action_got_it))
}
}
}
}
}
}
@@ -0,0 +1,47 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components
import com.meowarex.rlmobile.ui.screens.patchopts.components.*
import com.meowarex.rlmobile.ui.screens.patchopts.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import com.meowarex.rlmobile.ui.screens.patchopts.EffectiveVariant
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatchVariantSelector(
variants: List<EffectiveVariant>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
SingleChoiceSegmentedButtonRow(
modifier = modifier.fillMaxWidth(),
) {
variants.forEachIndexed { index, variant ->
SegmentedButton(
selected = index == selectedIndex,
enabled = variant.enabled,
onClick = { if (variant.enabled) onSelect(index) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = variants.size,
),
icon = {},
label = {
Text(
text = variant.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
},
)
}
}
}
@@ -0,0 +1,52 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.options
import 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()
}
}
@@ -0,0 +1,52 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.options
import 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),
)
}
}
@@ -0,0 +1,84 @@
package com.meowarex.rlmobile.ui.legacy.screens.patchopts.components.options
import 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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.legacy.components.Label
import com.meowarex.rlmobile.ui.legacy.components.ResetToDefaultButton
@Composable
fun TextPatchOption(
name: String,
description: String,
value: String,
valueIsError: Boolean,
valueIsDefault: Boolean,
onValueChange: (String) -> Unit,
onValueReset: () -> Unit,
modifier: Modifier = Modifier,
locked: Boolean? = null,
onLockClick: () -> Unit = {},
extra: (@Composable ColumnScope.() -> Unit)? = null,
) {
Label(
name = name,
description = description,
modifier = modifier,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
isError = valueIsError,
singleLine = true,
enabled = locked != true,
colors = OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
errorContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
),
trailingIcon = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (locked != true) {
ResetToDefaultButton(
enabled = !valueIsDefault,
onClick = onValueReset,
)
}
if (locked != null) {
IconButton(
onClick = onLockClick,
modifier = Modifier.size(32.dp),
) {
Icon(
painter = painterResource(
if (locked) R.drawable.ic_lock else R.drawable.ic_lock_open,
),
contentDescription = stringResource(
if (locked) R.string.patchopts_field_unlock else R.string.patchopts_field_lock,
),
tint = if (locked) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
Spacer(Modifier.width(8.dp))
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
)
extra?.invoke(this)
}
}
@@ -0,0 +1,224 @@
package com.meowarex.rlmobile.ui.legacy.screens.permissions
import 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.legacy.components.TextDivider
import com.meowarex.rlmobile.ui.legacy.components.settings.SettingsItem
import com.meowarex.rlmobile.ui.legacy.screens.home.HomeScreen
import com.meowarex.rlmobile.ui.legacy.screens.permissions.components.PermissionButton
import com.meowarex.rlmobile.ui.legacy.screens.permissions.components.PermissionsAppBar
import com.meowarex.rlmobile.ui.legacy.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 = "Legacy-Permissions"
override fun enter(lastEvent: StackEvent): EnterTransition? = EnterTransition.None
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = koinActivityViewModel<PermissionsModel>()
// Go back (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))
}
}
}
}
}
}
@@ -0,0 +1,80 @@
package com.meowarex.rlmobile.ui.legacy.screens.permissions.components
import com.meowarex.rlmobile.ui.screens.permissions.components.*
import com.meowarex.rlmobile.ui.screens.permissions.*
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))
}
}
}
}
@@ -0,0 +1,48 @@
package com.meowarex.rlmobile.ui.legacy.screens.permissions.components
import com.meowarex.rlmobile.ui.screens.permissions.components.*
import com.meowarex.rlmobile.ui.screens.permissions.*
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.legacy.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,251 @@
package com.meowarex.rlmobile.ui.legacy.screens.settings
import com.meowarex.rlmobile.ui.screens.settings.*
import android.os.Build
import android.os.Parcelable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.draw.alpha
import androidx.compose.ui.res.painterResource
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.legacy.components.BackButton
import com.meowarex.rlmobile.ui.legacy.components.MainActionButton
import com.meowarex.rlmobile.ui.legacy.components.settings.*
import com.meowarex.rlmobile.ui.legacy.screens.settings.components.InstallersDialog
import com.meowarex.rlmobile.ui.legacy.screens.settings.components.ThemeDialog
import com.meowarex.rlmobile.ui.legacy.screens.settings.components.UiStyleDialog
import com.meowarex.rlmobile.ui.util.paddings.*
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class SettingsScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key = "Legacy-Settings"
@Composable
override fun Content() {
val model = koinScreenModel<SettingsModel>()
var clearedCache by rememberSaveable { mutableStateOf(false) }
val preferences = model.preferences
if (model.showThemeDialog) {
ThemeDialog(
currentTheme = preferences.theme,
onDismiss = model::hideThemeDialog,
onConfirm = model::setTheme
)
}
if (model.showUiStyleDialog) {
UiStyleDialog(
current = preferences.uiStyle,
onDismiss = model::hideUiStyleDialog,
onConfirm = model::setUiStyle,
)
}
if (model.showInstallersDialog) {
InstallersDialog(
currentInstaller = preferences.installer,
onDismiss = model::hideInstallersDialog,
onConfirm = model::setInstaller,
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.navigation_settings)) },
navigationIcon = { BackButton() },
)
},
) { padding ->
LazyColumn(
contentPadding = padding
.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top)
.add(PaddingValues(bottom = 12.dp)),
modifier = Modifier
.fillMaxSize()
.padding(padding.exclude(PaddingValuesSides.Bottom))
) {
item(key = "HEADER_APPEARANCE", contentType = "DIVIDER") {
SettingsHeader(stringResource(R.string.settings_header_appearance))
}
item(key = "SETTING_THEME") {
SettingsItem(
modifier = Modifier.clickable(onClick = model::showThemeDialog),
icon = { Icon(painterResource(R.drawable.ic_brush), null) },
text = { Text(stringResource(R.string.setting_theme)) },
secondaryText = { Text(stringResource(R.string.setting_theme_desc)) }
) {
FilledTonalButton(onClick = model::showThemeDialog) {
Text(preferences.theme.toDisplayName())
}
}
}
item(key = "SETTING_UI_STYLE") {
SettingsItem(
modifier = Modifier.clickable(onClick = model::showUiStyleDialog),
icon = { Icon(painterResource(R.drawable.ic_sparkle), null) },
text = { Text(stringResource(R.string.setting_ui_style)) },
secondaryText = { Text(stringResource(R.string.setting_ui_style_desc)) }
) {
FilledTonalButton(onClick = model::showUiStyleDialog) {
Text(preferences.uiStyle.toDisplayName())
}
}
}
// Material You theming on Android 12+
if (Build.VERSION.SDK_INT >= 31) {
item(key = "SETTING_DYNAMIC_COLOR", contentType = "SETTING_SWITCH") {
SettingsSwitch(
label = stringResource(R.string.setting_dynamic_color),
secondaryLabel = stringResource(R.string.setting_dynamic_color_desc),
pref = preferences.dynamicColor,
icon = { Icon(painterResource(R.drawable.ic_palette), null) },
onPrefChange = { preferences.dynamicColor = it },
)
}
}
item(key = "SETTING_AUTO_UPDATE_CHECK", contentType = "SETTING_SWITCH") {
SettingsSwitch(
label = stringResource(R.string.setting_auto_update_check),
secondaryLabel = stringResource(R.string.setting_auto_update_check_desc),
pref = preferences.autoUpdateCheck,
icon = { Icon(painterResource(R.drawable.ic_update), null) },
onPrefChange = { model.setAutoUpdateCheck(it) },
)
}
item(key = "HEADER_INSTALL", contentType = "DIVIDER") {
SettingsHeader(stringResource(R.string.settings_header_installation))
}
item(key = "SETTING_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 = model::showInstallersDialog),
) {
FilledTonalButton(onClick = model::showInstallersDialog) {
Icon(
painter = preferences.installer.icon(),
contentDescription = null,
modifier = Modifier
.size(20.dp)
.padding(end = 6.dp),
)
Text(preferences.installer.title())
}
}
}
item(key = "SETTING_KEEP_APK", contentType = "SETTING_SWITCH") {
SettingsSwitch(
label = stringResource(R.string.setting_keep_patched_apks),
secondaryLabel = stringResource(R.string.setting_keep_patched_apks_desc),
icon = { Icon(painterResource(R.drawable.ic_delete_forever), null) },
pref = preferences.keepPatchedApks,
onPrefChange = { model.setKeepPatchedApks(it) },
)
}
if (preferences.keepPatchedApks) {
item(key = "BUTTON_EXPORT_APK", contentType = "BUTTON") {
MainActionButton(
text = stringResource(R.string.settings_export_apk),
icon = painterResource(R.drawable.ic_save),
enabled = model.patchedApkExists,
onClick = model::shareApk,
modifier = Modifier
.padding(start = 32.dp, end = 32.dp, top = 16.dp)
.fillMaxWidth()
.animateItem(),
)
}
}
item(key = "BUTTON_CACHE_CLEAR", contentType = "BUTTON") {
MainActionButton(
text = stringResource(R.string.settings_clear_cache),
icon = painterResource(R.drawable.ic_delete_forever),
enabled = !clearedCache,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
),
onClick = {
clearedCache = true
model.clearCache()
},
modifier = Modifier
.padding(start = 32.dp, end = 32.dp, top = 18.dp)
.fillMaxWidth()
)
}
item(key = "HEADER_ADVANCED", contentType = "DIVIDER") {
SettingsHeader(stringResource(R.string.settings_header_advanced))
}
item(key = "SETTING_DEVMODE", contentType = "SETTING_SWITCH") {
SettingsSwitch(
label = stringResource(R.string.setting_developer_options),
secondaryLabel = stringResource(R.string.setting_developer_options_desc),
pref = preferences.devMode,
icon = { Icon(painterResource(R.drawable.ic_code), null) },
onPrefChange = { preferences.devMode = it },
)
}
item(key = "HEADER_INFO", contentType = "DIVIDER") {
SettingsHeader(stringResource(R.string.settings_header_info))
}
item(key = "INFO") {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 36.dp, vertical = 12.dp),
) {
Text(
text = model.installInfo,
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f),
),
)
Spacer(Modifier.weight(1f, fill = true))
IconButton(onClick = model::copyInstallInfo) {
Icon(
painter = painterResource(R.drawable.ic_copy),
contentDescription = stringResource(R.string.action_copy),
modifier = Modifier
.size(28.dp)
.alpha(.8f),
)
}
}
}
}
}
}
}
@@ -0,0 +1,183 @@
package com.meowarex.rlmobile.ui.legacy.screens.settings.components
import com.meowarex.rlmobile.ui.screens.settings.components.*
import com.meowarex.rlmobile.ui.screens.settings.*
import androidx.compose.foundation.*
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.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.*
import com.meowarex.rlmobile.util.showToast
import com.topjohnwu.superuser.Shell
import org.koin.compose.koinInject
@Composable
fun InstallersDialog(
currentInstaller: InstallerSetting,
onDismiss: () -> Unit,
onConfirm: (InstallerSetting) -> Unit,
) {
val context = LocalContext.current
val shizuku = koinInject<ShizukuManager>()
val dhizuku = koinInject<DhizukuManager>()
var shizukuAvailable by remember { mutableStateOf(false) }
var dhizukuAvailable by remember { mutableStateOf(false) }
var selectedInstaller by rememberSaveable { mutableStateOf(currentInstaller) }
LaunchedEffect(Unit) {
shizukuAvailable = shizuku.shizukuAvailable()
dhizukuAvailable = dhizuku.dhizukuAvailable()
}
// Check if selected installer is usable and ask for permissions when necessary
LaunchedEffect(selectedInstaller) {
when (selectedInstaller) {
InstallerSetting.PackageInstaller -> {
// Once the Google sideloading block is in place,
// check whether it is applicable to the device, and if so then it needs
// to be inaccessible. (Disable button)
}
InstallerSetting.Root -> {
val shell = Shell.getShell()
if (!shell.isRoot) {
shell.waitAndClose()
Shell.getShell()
}
if (Shell.isAppGrantedRoot() != true) {
context.showToast(R.string.permissions_root_denied)
selectedInstaller = InstallerSetting.PackageInstaller
}
}
InstallerSetting.Intent -> {
// don't know whether this device supports this method
}
InstallerSetting.Shizuku -> {
if (!shizuku.requestPermissions()) {
selectedInstaller = InstallerSetting.PackageInstaller
}
}
InstallerSetting.Dhizuku -> {
if (!dhizuku.requestPermissions()) {
selectedInstaller = InstallerSetting.PackageInstaller
}
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_apk_install),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
title = { Text(stringResource(R.string.setting_installer)) },
text = {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
for (installer in InstallerSetting.entries) key(installer) {
InstallerItem(
installer = installer,
selected = installer == selectedInstaller,
enabled = when (installer) {
InstallerSetting.PackageInstaller -> true
InstallerSetting.Root -> true
InstallerSetting.Intent -> true
InstallerSetting.Shizuku -> shizukuAvailable
InstallerSetting.Dhizuku -> dhizukuAvailable
},
onClick = { selectedInstaller = installer },
)
}
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selectedInstaller)
onDismiss()
},
) {
Text(stringResource(R.string.action_apply))
}
},
)
}
@Composable
private fun InstallerItem(
installer: InstallerSetting,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.Companion.CenterVertically,
modifier = Modifier.Companion
.clickable(
indication = null,
interactionSource = interactionSource,
onClick = onClick,
)
.clip(MaterialTheme.shapes.medium)
.padding(horizontal = 6.dp, vertical = 8.dp),
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Companion.CenterVertically,
) {
Icon(
painter = installer.icon(),
contentDescription = null,
modifier = Modifier
.size(20.dp)
)
Text(
text = installer.title(),
style = MaterialTheme.typography.labelLarge
.copy(fontSize = 14.sp)
)
}
Text(
text = installer.description(),
style = MaterialTheme.typography.labelMedium.copy(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f),
),
)
}
Spacer(Modifier.Companion.weight(0.05f, true))
RadioButton(
selected = selected,
enabled = enabled,
onClick = onClick,
interactionSource = interactionSource,
)
}
}
@@ -0,0 +1,92 @@
package com.meowarex.rlmobile.ui.legacy.screens.settings.components
import com.meowarex.rlmobile.ui.screens.settings.components.*
import com.meowarex.rlmobile.ui.screens.settings.*
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.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.theme.Theme
@Composable
fun ThemeDialog(
currentTheme: Theme,
onDismiss: () -> Unit,
onConfirm: (Theme) -> Unit,
) {
var selectedTheme by rememberSaveable { mutableStateOf(currentTheme) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_brush),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
title = { Text(stringResource(R.string.setting_theme)) },
text = {
Column {
for (theme in Theme.entries) key(theme) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.Companion.CenterVertically,
modifier = Modifier.Companion
.clickable(
indication = null,
interactionSource = interactionSource,
onClick = { selectedTheme = theme },
)
.clip(MaterialTheme.shapes.medium)
.padding(horizontal = 6.dp, vertical = 8.dp),
) {
Icon(
painter = theme.toPainter(),
contentDescription = null,
modifier = Modifier
.padding(end = 14.dp)
.size(26.dp),
)
Text(
text = theme.toDisplayName(),
style = MaterialTheme.typography.labelLarge
.copy(fontSize = 14.sp)
)
Spacer(Modifier.Companion.weight(1f, true))
RadioButton(
selected = theme == selectedTheme,
onClick = { selectedTheme = theme },
interactionSource = interactionSource,
)
}
}
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selectedTheme)
onDismiss()
},
) {
Text(stringResource(R.string.action_apply))
}
},
)
}
@@ -0,0 +1,93 @@
package com.meowarex.rlmobile.ui.legacy.screens.settings.components
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.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.theme.UiStyle
/**
* The Legacy counterpart of the UI style picker
*
* Without this you are stuck in legacy style for literally ever, like until the aliens find us. ever..
*/
@Composable
fun UiStyleDialog(
current: UiStyle,
onDismiss: () -> Unit,
onConfirm: (UiStyle) -> Unit,
) {
var selected by rememberSaveable { mutableStateOf(current) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_sparkle),
contentDescription = null,
modifier = Modifier.size(32.dp),
)
},
title = { Text(stringResource(R.string.setting_ui_style)) },
text = {
Column {
for (style in UiStyle.entries) key(style) {
val interactionSource = remember(::MutableInteractionSource)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
indication = null,
interactionSource = interactionSource,
onClick = { selected = style },
)
.clip(MaterialTheme.shapes.medium)
.padding(horizontal = 6.dp, vertical = 8.dp),
) {
Icon(
painter = style.toPainter(),
contentDescription = null,
modifier = Modifier
.padding(end = 14.dp)
.size(26.dp),
)
Text(
text = style.toDisplayName(),
style = MaterialTheme.typography.labelLarge.copy(fontSize = 14.sp),
)
Spacer(Modifier.weight(1f, true))
RadioButton(
selected = style == selected,
onClick = { selected = style },
interactionSource = interactionSource,
)
}
}
}
},
confirmButton = {
Button(
onClick = {
onConfirm(selected)
onDismiss()
},
) {
Text(stringResource(R.string.action_apply))
}
},
)
}
@@ -0,0 +1,135 @@
package com.meowarex.rlmobile.ui.legacy.widgets.managerupdate
import com.meowarex.rlmobile.ui.widgets.managerupdate.*
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.legacy.components.Tag
@Composable
fun ManagerUpdateDialog(
deltas: List<VersionDelta>,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
FilledTonalButton(
onClick = onDismiss,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
Text(stringResource(R.string.action_continue))
}
},
title = {
Text(
text = stringResource(R.string.manager_update_title),
textAlign = TextAlign.Center,
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.manager_update_subtitle),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
deltas.forEach { delta ->
DeltaCard(delta = delta)
}
}
}
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_update),
contentDescription = null,
modifier = Modifier.size(36.dp),
)
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
),
)
}
@Composable
private fun DeltaCard(delta: VersionDelta) {
val changed = delta.from != null && delta.from != delta.to
val subtitle = when {
delta.from == null || delta.from == delta.to -> delta.to
else -> "${delta.from}${delta.to}"
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
) {
DeltaIcon(delta)
Column(modifier = Modifier.weight(1f)) {
Text(
text = delta.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = if (changed) MaterialTheme.colorScheme.primary
else LocalContentColor.current.copy(alpha = 0.65f),
fontWeight = if (changed) FontWeight.SemiBold else FontWeight.Normal,
)
}
delta.tag?.let { Tag(text = it) }
}
}
}
@Composable
private fun DeltaIcon(delta: VersionDelta) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.size(40.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Icon(
painter = painterResource(delta.iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
}
}
@@ -0,0 +1,105 @@
package com.meowarex.rlmobile.ui.legacy.widgets.updater
import com.meowarex.rlmobile.ui.widgets.updater.*
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.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.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.meowarex.rlmobile.R
import org.koin.androidx.compose.koinViewModel
@Composable
fun UpdaterDialog(
viewModel: UpdaterViewModel = koinViewModel(),
) {
if (!viewModel.showDialog) return
val isWorking by viewModel.isWorking.collectAsState()
val downloadProgress by viewModel.progress.collectAsState()
val downloadInProgress by remember { derivedStateOf { downloadProgress != null } }
AlertDialog(
confirmButton = {
FilledTonalButton(
onClick = viewModel::triggerUpdate,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) {
if (!isWorking) {
Text(stringResource(R.string.action_update))
} else if (!downloadInProgress) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onSecondary,
strokeWidth = 2.5.dp,
modifier = Modifier.size(20.dp)
)
} else {
CircularProgressIndicator(
progress = { downloadProgress ?: 1f },
color = MaterialTheme.colorScheme.onSecondary,
trackColor = MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.6f),
strokeWidth = 2.5.dp,
modifier = Modifier.size(20.dp)
)
}
}
},
dismissButton = {
TextButton(
onClick = viewModel::dismissDialog,
enabled = !isWorking,
) {
Text(stringResource(R.string.action_dismiss))
}
},
onDismissRequest = {},
title = {
Text(stringResource(R.string.updater_title, viewModel.targetVersion ?: ""))
},
text = {
val uriHandler = LocalUriHandler.current
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.updater_body),
textAlign = TextAlign.Center,
)
TextButton(
onClick = { uriHandler.openUri(viewModel.targetReleaseUrl!!) }
) {
Text(
text = stringResource(R.string.updater_open_github),
textAlign = TextAlign.Center,
textDecoration = TextDecoration.Underline,
)
}
}
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
modifier = Modifier.size(36.dp),
)
},
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
),
)
}
@@ -85,7 +85,7 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") {
Box(
contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() },
content = { LoadingIndicator() },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 38.dp),
@@ -21,6 +21,8 @@ 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.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.screens.componentopts.components.*
import com.meowarex.rlmobile.ui.util.ScreenWithResult
import com.meowarex.rlmobile.ui.util.paddings.*
@@ -160,11 +162,11 @@ fun ComponentOptionsScreenContent(
.fillMaxWidth()
.padding(top = 10.dp),
) {
FilledTonalButton(
RadiantButton(
text = stringResource(R.string.action_confirm),
onClick = onBackPressed,
) {
Text(stringResource(R.string.action_confirm))
}
size = RadiantButtonSize.Small,
)
}
}
}
@@ -312,18 +314,16 @@ private fun ReleaseRow(
color = LocalContentColor.current.copy(alpha = 0.55f),
)
}
FilledTonalButton(
onClick = onImport,
enabled = !anyImporting,
) {
if (importing) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp),
)
} else {
Text(stringResource(R.string.action_install))
}
// Swap the button out for the indicator while this specific release imports
if (importing) {
CircularWavyProgressIndicator(modifier = Modifier.size(30.dp))
} else {
RadiantButton(
text = stringResource(R.string.action_install),
onClick = onImport,
enabled = !anyImporting,
size = RadiantButtonSize.Small,
)
}
}
}
@@ -19,9 +19,7 @@ fun ComponentOptionsAppBar(
TopAppBar(
navigationIcon = { BackButton() },
title = {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(R.string.componentopts_screen_title, componentType.name))
Text(
text = stringResource(R.string.componentopts_screen_desc),
@@ -17,6 +17,8 @@ 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.ui.components.radiant.RadiantIconButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantRadio
import com.meowarex.rlmobile.network.utils.SemVer
import kotlin.time.Instant
@@ -65,15 +67,13 @@ fun PatchComponentCard(
Spacer(Modifier.weight(1f, fill = true))
IconButton(
RadiantIconButton(
icon = painterResource(R.drawable.ic_delete_forever),
contentDescription = stringResource(R.string.action_delete),
tint = MaterialTheme.colorScheme.error,
subtle = true,
onClick = onDelete,
) {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
tint = MaterialTheme.colorScheme.error,
contentDescription = stringResource(R.string.action_delete),
)
}
)
}
}
@@ -103,7 +103,7 @@ fun PatchComponentCardBase(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp),
) {
RadioButton(
RadiantRadio(
selected = selected,
onClick = onSelect,
interactionSource = interaction,
@@ -211,6 +211,14 @@ class HomeModel(
return createPrefilledPatchOptsScreen(current.packageName)
}
/**
* The prefilled options behind [createRepatchScreen] without the Screen wrapper (needed for legacy ui)
*/
fun createRepatchOptions(): PatchOptions? {
val current = (state as? HomeState.Loaded)?.install ?: return null
return prefilledPatchOptions(current.packageName)
}
fun openApp(packageName: String) {
val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
@@ -228,11 +236,12 @@ class HomeModel(
application.startActivity(launchIntent)
}
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen {
val patchOptions = loadInstallMetadata(packageName)?.options
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen =
PatchOptionsScreen(prefilledOptions = prefilledPatchOptions(packageName))
fun prefilledPatchOptions(packageName: String): PatchOptions =
loadInstallMetadata(packageName)?.options
?: PatchOptions.Default.copy(packageName = packageName)
return PatchOptionsScreen(prefilledOptions = patchOptions)
}
private fun loadInstallMetadata(packageName: String): InstallMetadata? = try {
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0)
@@ -1,11 +1,12 @@
package com.meowarex.rlmobile.ui.screens.home
import android.os.Parcelable
import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -14,7 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -29,8 +30,13 @@ 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.SegmentedButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonStyle
import com.meowarex.rlmobile.ui.components.radiant.RadiantIconButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantCard
import com.meowarex.rlmobile.ui.components.Tag
import com.meowarex.rlmobile.ui.theme.radiantStyle
import com.meowarex.rlmobile.ui.screens.about.AboutScreen
import com.meowarex.rlmobile.ui.screens.home.components.CommitList
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
@@ -42,6 +48,15 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.androidx.compose.koinViewModel
// Connected group shapes (i'm pretty sure these are the segment control replacements, but i havent worked on this in months so fuck knows)
private val ConnectedLeadingShape = RoundedCornerShape(
topStart = 20.dp, bottomStart = 20.dp, topEnd = 6.dp, bottomEnd = 6.dp,
)
private val ConnectedTrailingShape = RoundedCornerShape(
topStart = 6.dp, bottomStart = 6.dp, topEnd = 20.dp, bottomEnd = 20.dp,
)
private val ConnectedPressedShape = RoundedCornerShape(8.dp)
@Parcelize
class HomeScreen : Screen, Parcelable {
@IgnoredOnParcel
@@ -63,35 +78,37 @@ class HomeScreen : Screen, Parcelable {
Scaffold(
topBar = {
// Compact bar
TopAppBar(
title = { Text(stringResource(R.string.navigation_home)) },
title = { Text(stringResource(R.string.rlmobile)) },
actions = {
IconButton(onClick = { model.refresh() }) {
Icon(
painterResource(R.drawable.ic_refresh),
contentDescription = stringResource(R.string.navigation_refresh),
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_refresh),
contentDescription = stringResource(R.string.navigation_refresh),
subtle = true,
onClick = { model.refresh() },
)
if (managerUpdateAvailable) {
IconButton(onClick = updater::reopenDialog) {
Icon(
painterResource(R.drawable.ic_update),
contentDescription = stringResource(R.string.action_update),
)
}
}
IconButton(onClick = { navigator.push(AboutScreen()) }) {
Icon(
painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator.push(SettingsScreen()) }) {
Icon(
painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings),
RadiantIconButton(
icon = painterResource(R.drawable.ic_update),
contentDescription = stringResource(R.string.action_update),
accent = true,
size = 40.dp,
onClick = updater::reopenDialog,
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about),
subtle = true,
onClick = { navigator.push(AboutScreen()) },
)
RadiantIconButton(
icon = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings),
subtle = true,
onClick = { navigator.push(SettingsScreen()) },
)
},
)
},
@@ -101,15 +118,17 @@ class HomeScreen : Screen, Parcelable {
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(pv)
.padding(16.dp)
.padding(horizontal = 16.dp)
.fillMaxSize(),
) {
val state = model.state
when (state) {
when (val state = model.state) {
HomeState.Loading -> Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
) { CircularProgressIndicator() }
) {
// Expressive loader (a sequence of morphing polygons rather than a spinner.) [M3E is cool i guess]
ContainedLoadingIndicator()
}
is HomeState.Loaded -> HomeContent(
state = state,
@@ -153,6 +172,83 @@ private fun ColumnScope.HomeContent(
?.let { "v${it.name} (build ${it.code})" }
val latestVersionName = state.latestTidalVersionCode?.let { "build $it" }
val patchesBehind = install != null && install.patchesUpToDate == false
val tidalBehind = install != null && install.tidalUpToDate == false
InstallHeroCard(
install = install,
currentVersionName = currentVersionName,
latestVersionName = latestVersionName,
patchesBehind = patchesBehind,
tidalBehind = tidalBehind,
onLaunch = onLaunch,
onInfo = onInfo,
)
val blockedByManagerUpdate = managerUpdateAvailable && (patchesBehind || tidalBehind)
val canLocalRepatch = install != null && state.offlineRepatchReady
val onlineEnabled = state.latestTidalVersionCode != null || install != null
val buttonEnabled = !blockedByManagerUpdate && (if (state.offline) canLocalRepatch else onlineEnabled)
val label = when {
blockedByManagerUpdate -> "Manager Update Required"
state.offline && install != null -> "Local Repatch"
state.offline -> "No Network"
install == null && state.latestTidalVersionCode == null -> "Loading…"
install == null -> "Install"
patchesBehind && tidalBehind -> "Update Patches & TIDAL"
patchesBehind -> "Update Patches"
tidalBehind -> "Update TIDAL"
else -> "Repatch"
}
// The main button (repatch or update button)
RadiantButton(
text = label,
icon = painterResource(
if (install == null) R.drawable.ic_download else R.drawable.ic_sparkle
),
onClick = if (install == null) onInstall else onRepatch,
enabled = buttonEnabled,
style = RadiantButtonStyle.Accent,
size = RadiantButtonSize.Large,
fillWidth = true,
)
if (state.offline && install != null) {
Text(
text = "No Network Connection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
RadiantCard(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
CommitList(commits = commits.collectAsLazyPagingItems())
}
Spacer(Modifier.height(4.dp))
}
/**
* The identity block: (what is installed & which build etc etc whatever)
*/
@Composable
private fun InstallHeroCard(
install: InstallData?,
currentVersionName: String?,
latestVersionName: String?,
patchesBehind: Boolean,
tidalBehind: Boolean,
onLaunch: () -> Unit,
onInfo: () -> Unit,
) {
val fallbackPainter = if (install?.icon == null) {
// R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode.
val context = LocalContext.current
@@ -163,113 +259,108 @@ private fun ColumnScope.HomeContent(
} else null
val iconPainter = install?.icon ?: fallbackPainter
if (iconPainter != null) {
Image(
painter = iconPainter,
contentDescription = null,
modifier = Modifier
.size(60.dp)
.clip(CircleShape),
)
}
Text(
text = install?.name ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
)
val hero = radiantStyle.heroCard
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
AnimatedVisibility(visible = currentVersionName != null) {
Text(
text = "Current: ${currentVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
AnimatedVisibility(visible = latestVersionName != null) {
Text(
text = "Latest: ${latestVersionName ?: "-"}",
style = MaterialTheme.typography.labelLarge,
color = LocalContentColor.current.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
)
}
}
val patchesBehind = install != null && install.patchesUpToDate == false
val tidalBehind = install != null && install.tidalUpToDate == false
AnimatedVisibility(visible = patchesBehind || tidalBehind) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
if (patchesBehind) Tag(text = "New Patches!")
if (tidalBehind) Tag(text = "TIDAL Update!")
}
}
val blockedByManagerUpdate = managerUpdateAvailable && (patchesBehind || tidalBehind)
val canLocalRepatch = install != null && state.offlineRepatchReady
val onlineEnabled = state.latestTidalVersionCode != null || install != null
val buttonEnabled = !blockedByManagerUpdate && (if (state.offline) canLocalRepatch else onlineEnabled)
Button(
onClick = if (install == null) onInstall else onRepatch,
enabled = buttonEnabled,
modifier = Modifier.fillMaxWidth(),
) {
val label = when {
blockedByManagerUpdate -> "Manager Update Required"
state.offline && install != null -> "Local Repatch"
state.offline -> "No Network"
install == null && state.latestTidalVersionCode == null -> "Loading…"
install == null -> "Install"
patchesBehind && tidalBehind -> "Update Patches & TIDAL"
patchesBehind -> "Update Patches"
tidalBehind -> "Update TIDAL"
else -> "Repatch"
}
Text(
text = label,
textAlign = TextAlign.Center,
maxLines = 1,
modifier = Modifier
.basicMarquee()
.fillMaxWidth(),
)
}
if (state.offline && install != null) {
Text(
text = "No Network Connection",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
// Legacy keeps the original flat centred stack rather than wrapping it in a raised card. (thx claude)
HeroContainer(hero) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
)
}
AnimatedVisibility(visible = install != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(RoundedCornerShape(16.dp)),
.padding(horizontal = 18.dp, vertical = 18.dp),
) {
SegmentedButton(
icon = painterResource(R.drawable.ic_launch),
text = stringResource(R.string.action_launch),
onClick = onLaunch,
)
SegmentedButton(
icon = painterResource(R.drawable.ic_info),
text = stringResource(R.string.action_open_info),
onClick = onInfo,
if (iconPainter != null) {
// Clipped to a 9-sided cookie rather than a circle — the app icon is the one place
// a non-rectilinear silhouette reads as deliberate instead of noisy. (thx claude again)
Image(
painter = iconPainter,
contentDescription = null,
modifier = Modifier
.size(60.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
)
}
Text(
text = install?.name ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
if (currentVersionName != null || latestVersionName != null) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
currentVersionName?.let {
Text(
text = "Current: $it",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
latestVersionName?.let {
Text(
text = "Latest: $it",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
AnimatedVisibility(visible = patchesBehind || tidalBehind) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (patchesBehind) Tag(
text = "New Patches!",
icon = painterResource(R.drawable.ic_sparkle),
)
if (tidalBehind) Tag(
text = "TIDAL Update!",
icon = painterResource(R.drawable.ic_update),
tint = MaterialTheme.colorScheme.tertiary,
)
}
}
AnimatedVisibility(visible = install != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
RadiantButton(
text = stringResource(R.string.action_launch),
icon = painterResource(R.drawable.ic_launch),
onClick = onLaunch,
modifier = Modifier.weight(1f),
)
RadiantButton(
text = stringResource(R.string.action_open_info),
icon = painterResource(R.drawable.ic_info),
onClick = onInfo,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
CommitList(commits = commits.collectAsLazyPagingItems())
/**
* Wraps the home identity block in a raised card for Radiant (renders it inline for Legacy UI)
*/
@Composable
private fun HeroContainer(
carded: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
if (carded) {
RadiantCard(modifier = Modifier.fillMaxWidth(), content = content)
} else {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}
@@ -1,6 +1,8 @@
package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.foundation.clickable
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
@@ -59,7 +61,11 @@ fun CommitList(commits: LazyPagingItems<GithubCommit>) {
modifier = Modifier.fillMaxWidth().padding(16.dp),
) {
Text("Failed to load commits", style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center)
Button(onClick = { commits.retry() }) { Text("Retry") }
RadiantButton(
text = "Retry",
onClick = { commits.retry() },
size = RadiantButtonSize.Small,
)
}
}
}
@@ -5,6 +5,7 @@ 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.radiant.RadiantIconButton
import com.meowarex.rlmobile.ui.components.BackButton
@Composable
@@ -16,18 +17,18 @@ fun LogAppBar(
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),
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_save),
contentDescription = stringResource(R.string.log_action_export),
subtle = true,
onClick = onExportLog,
)
RadiantIconButton(
icon = painterResource(R.drawable.ic_share),
contentDescription = stringResource(R.string.log_action_share),
subtle = true,
onClick = onShareLog,
)
},
)
}
@@ -25,6 +25,7 @@ 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.DangerActionButton
import com.meowarex.rlmobile.ui.components.MainActionButton
import com.meowarex.rlmobile.ui.components.Wakelock
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
@@ -130,6 +131,14 @@ class PatchingScreen(
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
) {
OverallProgressBar(
steps = model.steps,
state = state,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp),
@@ -178,9 +187,9 @@ class PatchingScreen(
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,
iconColor = MaterialTheme.customColors.success,
outlineColor = null,
containerColor = MaterialTheme.customColors.successContainer,
modifier = Modifier
.padding(bottom = VERTICAL_PADDING)
.fillMaxWidth(),
@@ -249,13 +258,10 @@ class PatchingScreen(
}
}
MainActionButton(
DangerActionButton(
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()
@@ -13,4 +13,7 @@ val PatchingScreenState.isProgressChange: Boolean
inline get() = this != CloseScreen
val PatchingScreenState.isFinished: Boolean
inline get() = isProgressChange || this == CloseScreen
// Was `isProgressChange || this == CloseScreen`, which expands to `x != CloseScreen ||
// x == CloseScreen` — a tautology, so this was unconditionally true and the battery
// optimisation banner it gates could never appear. (thx claude)
inline get() = this is PatchingScreenState.Success || this is PatchingScreenState.Failed
@@ -0,0 +1,96 @@
package com.meowarex.rlmobile.ui.screens.patching.components
import androidx.compose.animation.core.animateFloatAsState
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.stringResource
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.StepGroup
import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreenState
import com.meowarex.rlmobile.ui.screens.patching.isFinished
import com.meowarex.rlmobile.ui.theme.customColors
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlin.math.roundToInt
/**
* Whole install progress
*/
@Composable
fun OverallProgressBar(
steps: ImmutableMap<StepGroup, ImmutableList<Step>>?,
state: PatchingScreenState,
modifier: Modifier = Modifier,
) {
val allSteps = remember(steps) { steps?.values?.flatten().orEmpty() }
if (allSteps.isEmpty()) return
val completed = allSteps.count { it.state.isFinished }
val fraction = completed.toFloat() / allSteps.size
val animatedFraction by animateFloatAsState(
targetValue = fraction,
label = "OverallProgress",
)
val failed = state is PatchingScreenState.Failed
val succeeded = state is PatchingScreenState.Success
val trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
val indicatorColor = when {
failed -> MaterialTheme.colorScheme.error
succeeded -> MaterialTheme.customColors.success
else -> MaterialTheme.colorScheme.primary
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.padding(bottom = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(
R.string.installer_progress_summary,
completed,
allSteps.size,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.weight(1f))
Text(
text = "${(animatedFraction * 100).roundToInt()}%",
style = MaterialTheme.typography.labelLarge,
color = indicatorColor,
)
}
// finished run gets the flat determinate bar a live one keeps its wave (M3E)
if (state.isFinished) {
LinearProgressIndicator(
progress = { animatedFraction },
color = indicatorColor,
trackColor = trackColor,
modifier = Modifier
.fillMaxWidth()
.height(10.dp),
)
} else {
LinearWavyProgressIndicator(
progress = { animatedFraction },
color = indicatorColor,
trackColor = trackColor,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -5,6 +5,7 @@ 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.radiant.RadiantIconButton
@Composable
fun PatchingAppBar(
@@ -13,12 +14,12 @@ fun PatchingAppBar(
TopAppBar(
title = { Text(stringResource(R.string.installer)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
)
}
RadiantIconButton(
icon = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
subtle = true,
onClick = onBack,
)
}
)
}
@@ -14,6 +14,7 @@ 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 com.meowarex.rlmobile.ui.theme.radiantSurface
import kotlinx.collections.immutable.ImmutableList
@Composable
@@ -56,8 +57,12 @@ fun StepGroupCard(
Column(
modifier = modifier
.radiantSurface(
shape = MaterialTheme.shapes.large,
// running group lights its edge so the active stage is obvious
active = groupState == StepState.Running,
)
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -101,7 +106,8 @@ fun StepGroupCard(
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
// solid darker container cause opinions exist
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
.fillMaxWidth()
.padding(20.dp)
.padding(start = 4.dp)
@@ -7,7 +7,6 @@ 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
@@ -15,7 +14,7 @@ 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 com.meowarex.rlmobile.ui.theme.customColors
import kotlin.math.roundToInt
@Composable
@@ -40,12 +39,11 @@ fun StepStateIcon(
)
StepState.Running -> {
val strokeWidth = Dp(floor(size.value / 10) + 1)
if (stepProgress > .05f) {
CircularProgressIndicator(
// Determinate work gets the wavy indicator — the amplitude of the wave is
// what distinguishes "actively moving" from a stalled determinate arc. (claudes idea)
CircularWavyProgressIndicator(
progress = { animatedProgress },
strokeWidth = strokeWidth,
modifier = Modifier
.size(size)
.semantics { contentDescription = "${(stepProgress * 100).roundToInt()}%" },
@@ -53,9 +51,7 @@ fun StepStateIcon(
} else {
val description = stringResource(R.string.status_ongoing)
// Infinite spinner
CircularProgressIndicator(
strokeWidth = strokeWidth,
CircularWavyProgressIndicator(
modifier = Modifier
.size(size)
.semantics { contentDescription = description },
@@ -66,7 +62,7 @@ fun StepStateIcon(
StepState.Success -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_success),
tint = Color(0xFF59B463),
tint = MaterialTheme.customColors.success,
modifier = Modifier.size(size)
)
@@ -80,7 +76,7 @@ fun StepStateIcon(
StepState.Skipped -> Icon(
painter = painterResource(R.drawable.ic_check_circle),
contentDescription = stringResource(R.string.status_skipped),
tint = Color(0xFFAEAEAE),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size)
)
}
@@ -29,10 +29,10 @@ fun TextBanner(
border(
width = 2.dp,
color = color,
shape = MaterialTheme.shapes.medium,
shape = MaterialTheme.shapes.large,
)
}
.clip(MaterialTheme.shapes.medium)
.clip(MaterialTheme.shapes.large)
.background(containerColor)
.thenIf(onClick) { clickable(onClick = it) }
) {
@@ -7,6 +7,7 @@ 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.ui.util.ScreenWithResult
import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.manager.PathManager
@@ -190,7 +191,9 @@ class PatchOptionsModel(
fun isAdvancedModified(spec: PatchSpec): Boolean = spec.advancedOptions.any { option ->
when (option) {
is OptionSpec.Toggle -> toggleValue(spec, option) != option.default
// inline toggles sit next to the variant picker, not in the sheet, so they
// must not light up the sheet's "modified" dot
is OptionSpec.Toggle -> !option.inline && toggleValue(spec, option) != option.default
is OptionSpec.Slider -> sliderValue(spec, option) != option.default
is OptionSpec.Choice -> choiceValue(spec, option) != option.defaultIndex
is OptionSpec.Color -> colorValue(spec, option) != option.default
@@ -250,22 +253,24 @@ class PatchOptionsModel(
var customPatches by mutableStateOf<PatchComponent?>(null)
private set
fun selectCustomTidalApk(navigator: Navigator) = screenModelScope.launch {
customTidalApk = navigator.pushForResult(
ComponentOptionsScreen(
default = customTidalApk,
componentType = PatchComponent.Type.TidalApk,
)
)
// The picker screen is supplied by the caller so the Legacy UI can push its own component
// screen instead of the Radiant one. Defaults keep every existing call site unchanged. (claudes bug fix)
fun selectCustomTidalApk(
navigator: Navigator,
screen: (PatchComponent?) -> ScreenWithResult<PatchComponent?> = { default ->
ComponentOptionsScreen(default = default, componentType = PatchComponent.Type.TidalApk)
},
) = screenModelScope.launch {
customTidalApk = navigator.pushForResult(screen(customTidalApk))
}
fun selectCustomPatches(navigator: Navigator) = screenModelScope.launch {
customPatches = navigator.pushForResult(
ComponentOptionsScreen(
default = customPatches,
componentType = PatchComponent.Type.Patches,
)
)
fun selectCustomPatches(
navigator: Navigator,
screen: (PatchComponent?) -> ScreenWithResult<PatchComponent?> = { default ->
ComponentOptionsScreen(default = default, componentType = PatchComponent.Type.Patches)
},
) = screenModelScope.launch {
customPatches = navigator.pushForResult(screen(customPatches))
reloadSpecs(customPatches)
}
@@ -16,6 +16,8 @@ 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.radiant.RadiantButton
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.*
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.screens.patching.PatchingScreen
@@ -252,12 +254,12 @@ fun PatchOptionsScreenContent(
description = stringResource(R.string.patchopts_custom_tidal_apk_desc),
modifier = Modifier.clickable(onClick = onSelectCustomTidalApk),
) {
FilledTonalButton(onClick = onSelectCustomTidalApk) {
Text(
text = customTidalApk?.version?.toString()
?: stringResource(R.string.componentopts_selected_none)
)
}
RadiantButton(
text = customTidalApk?.version?.toString()
?: stringResource(R.string.componentopts_selected_none),
onClick = onSelectCustomTidalApk,
size = RadiantButtonSize.Small,
)
}
IconPatchOption(
@@ -266,12 +268,12 @@ fun PatchOptionsScreenContent(
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)
)
}
RadiantButton(
text = customPatches?.version?.toString()
?: stringResource(R.string.componentopts_selected_none),
onClick = onSelectCustomPatches,
size = RadiantButtonSize.Small,
)
}
SwitchPatchOption(
@@ -285,18 +287,13 @@ fun PatchOptionsScreenContent(
Spacer(Modifier.weight(1f))
FilledTonalButton(
MainActionButton(
text = stringResource(R.string.action_install),
icon = painterResource(R.drawable.ic_download),
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))
}
modifier = Modifier.padding(bottom = 10.dp),
)
}
}
}
@@ -1,6 +1,7 @@
package com.meowarex.rlmobile.ui.screens.patchopts
import android.content.Context
import android.os.Build
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import kotlinx.serialization.SerialName
@@ -49,7 +50,13 @@ data class VariantSpec(
val fileNames: List<String> = emptyList(),
val extensionFiles: List<String> = emptyList(),
val enabled: Boolean = true,
)
/**
* Hidden from the picker on devices below this API level (AGSL needs 33)
*/
val minSdk: Int = 0,
) {
val supportedHere: Boolean get() = Build.VERSION.SDK_INT >= minSdk
}
/** A single advanced option. Discriminated by `"type"` in JSON. */
@Serializable
@@ -101,6 +108,8 @@ sealed interface OptionSpec {
val token: String? = null,
/** How the chosen value becomes the smali literal that replaces the token. */
val encode: SmaliEncode? = null,
/** Hidden unless this variant index is the selected one. */
val requiresVariant: Int? = null,
) : OptionSpec {
val valueRange: ClosedFloatingPointRange<Float> get() = min..max
}
@@ -187,7 +196,9 @@ fun PatchSpec.effectiveVariants(isOptionOn: (OptionSpec.Toggle) -> Boolean): Lis
}
}
return variants.mapIndexedNotNull { index, variant ->
if (index in hidden) null
// Everything downstream (variantIndex -> resolveVariantIndex) picks from this list, so
// dropping an unsupported variant here also keeps the patcher from ever applying it.
if (index in hidden || !variant.supportedHere) null
else EffectiveVariant(index, relabel[index] ?: variant.title, variant.enabled)
}
}
@@ -206,6 +217,20 @@ fun PatchSpec.resolveVariantIndex(stored: Int, isOptionOn: (OptionSpec.Toggle) -
?: visible.first().originalIndex
}
/**
* The variant this option belongs to
*/
val OptionSpec.variantGate: Int?
get() = when (this) {
is OptionSpec.Toggle -> requiresVariant
is OptionSpec.Slider -> requiresVariant
is OptionSpec.Choice -> null
is OptionSpec.Color -> null
}
fun OptionSpec.hiddenForVariant(selectedVariant: Int): Boolean =
variantGate.let { it != null && it != selectedVariant }
sealed interface OptionLock {
data object Free : OptionLock
data class NeedsVariant(val title: String) : OptionLock
@@ -13,6 +13,7 @@ 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
import com.meowarex.rlmobile.ui.theme.customColors
@Composable
fun PackageNameStateLabel(
@@ -39,7 +40,7 @@ fun PackageNameStateLabel(
PackageNameState.Ok -> Triple(
R.string.patchopts_pkgname_ok,
R.drawable.ic_check_circle,
Color(0xFF59B463),
MaterialTheme.customColors.success,
)
}
@@ -1,19 +1,11 @@
package com.meowarex.rlmobile.ui.screens.patchopts.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.AnnotatedString
@@ -21,68 +13,54 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.RadiantDialog
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
/**
* Confirms unlocking the package-name field, warning that a custom name
* blocks the listed patches/integrations from working.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PackageNameUnlockDialog(
blockedTitles: List<String>,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
) {
Column(
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 20.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = stringResource(R.string.patchopts_pkgname_warning_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
text = AnnotatedString.fromHtml(
stringResource(R.string.patchopts_pkgname_warning_msg, PatchOptions.Default.packageName),
RadiantDialog(
onDismissRequest = onDismiss,
title = stringResource(R.string.patchopts_pkgname_warning_title),
confirmText = stringResource(R.string.action_continue),
onConfirm = onConfirm,
destructive = true,
dismissText = stringResource(R.string.action_cancel),
onDismissAction = onDismiss,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = AnnotatedString.fromHtml(
stringResource(
R.string.patchopts_pkgname_warning_msg,
PatchOptions.Default.packageName,
),
style = MaterialTheme.typography.bodyLarge,
)
if (blockedTitles.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(start = 4.dp),
) {
for (title in blockedTitles) {
Text(
text = "$title",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
)
}
}
}
Text(
text = stringResource(R.string.patchopts_pkgname_warning_permanent),
style = MaterialTheme.typography.bodyLarge,
)
Box(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.align(Alignment.CenterEnd)) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.action_continue))
}
),
)
if (blockedTitles.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(start = 4.dp),
) {
for (title in blockedTitles) {
Text(
text = "$title",
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
}
}
}
Text(stringResource(R.string.patchopts_pkgname_warning_permanent))
}
}
}

Some files were not shown because too many files have changed in this diff Show More