Compare commits

...
80 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
meoware.exe 2150b87371 Merge pull request #71 from meowarex/dev
Word by Word Lyrics <3
2026-07-18 15:04:56 +10:00
meoware.exe 0d82c16168 Readme Update 2026-07-18 15:04:31 +10:00
meoware.exe 4c257f7ad9 Word by Word Lyrics <3 2026-07-18 15:02:07 +10:00
meoware.exe 7458df8eba Merge pull request #69 from meowarex/dev
Lyrics Button Hotfix <3
2026-07-17 00:01:18 +10:00
meoware.exe 6a5771cf9e Readme changes <3 2026-07-17 00:00:49 +10:00
meoware.exe a8a576c994 Lyrics Button Hotfix <3 2026-07-16 23:00:03 +10:00
meoware.exe 585666c403 Merge pull request #68 from meowarex/dev
Player Backdrop Image Settings <3
2026-07-14 01:54:31 +10:00
meoware.exe 90c008082f Player Backdrop Image Settings <3 2026-07-14 01:33:30 +10:00
meoware.exe c2a2586306 Merge pull request #67 from meowarex/dev
Upgrade Tidal Version + Native Blur <3
2026-07-14 00:35:34 +10:00
meoware.exe 2291f4064e Upgrade Tidal Version + Native Blur <3 2026-07-14 00:31:41 +10:00
meoware.exe f6a4de0cc7 Fix Missing Blocks on Patches <3 2026-07-06 16:56:56 +10:00
meoware.exe 4911e7703d Merge pull request #66 from meowarex/dev
Nav Bar Color Hotfix <3
2026-07-06 14:25:27 +10:00
meoware.exe f9a174eafa Nav Bar Color Hotfix <3 2026-07-06 14:24:51 +10:00
meoware.exe 49fe4c0d91 Merge pull request #65 from meowarex/dev
APK Bump + Offline Support + Playing From
2026-07-05 23:03:16 +10:00
meoware.exe 189e3d28a2 Offline Repatch + Force Incompatible (Dev Option) 2026-07-05 23:00:14 +10:00
meoware.exe aae2f54fab Update APK Version & Add Playing From 2026-07-05 20:58:22 +10:00
meoware.exe 191dcca239 Merge pull request #64 from meowarex/dev
Waze Integration + Integrations UI <3
2026-07-03 20:20:14 +10:00
meoware.exe 08aee155c9 Integrations UI + Package Name Warnings/Gate <3 2026-07-03 20:19:16 +10:00
meoware.exe 47c9c37236 Merge pull request #63 from Se7en-dev/feat/waze-support-patch
[PATCH] Waze Integration
2026-07-03 15:46:39 +10:00
Se7en-dev 50b99b6193 feat(waze patch): customizable themeColor 2026-07-02 22:56:09 +02:00
Se7en-dev 03271bf26d feat(waze patch): Tidal cyan accent color for waze 2026-07-02 20:44:24 +02:00
Se7en-dev 75f8187526 feat(waze patch): waze media menu browse source options 2026-07-02 20:26:12 +02:00
Se7en-dev 523f0a342b feat(patch): waze integration patch 2026-07-02 15:49:18 +02:00
meoware.exe fb4c776c61 Merge pull request #60 from meowarex/dev
Fold Cover Everywhere -> Player Backdrop
2026-06-26 23:02:05 +10:00
meoware.exe cbf04d9fa7 Fold Cover Everywhere -> Player Backdrop 2026-06-26 23:01:26 +10:00
meoware.exe 266cd184dd Merge pull request #59 from meowarex/dev
Improve Swipe Gstures <3
2026-06-26 22:38:36 +10:00
meoware.exe 04ea66acdb Improve Swipe Gstures <3 2026-06-26 22:38:04 +10:00
meoware.exe 22526f4e0b Merge pull request #58 from Se7en-dev/fix/mini-player-gestures-swipe-up-android-gestures-nav-fix
Fix : Mini Player Gestures - Android gesture navigation swipe up gesture fix + enable drag option + visual swipe up feedback
2026-06-26 19:02:36 +10:00
Se7en-dev 5a3ad2df7a fix(MP gestures): axis locking + swipe up feedback reset fixes 2026-06-26 10:59:11 +02:00
Se7en-dev fa99d10134 lint(MP gestures enable drag option): remove now useless text 2026-06-26 00:00:12 +02:00
Se7en-dev 4e8af5960c fix(mini player gestures): scope swipe up gesture to track area 2026-06-25 23:57:46 +02:00
Se7en-dev 09f6e8d34f feat(mini player gestures): enable drag option 2026-06-25 21:58:27 +02:00
meoware.exe 160be11ac4 Merge pull request #55 from meowarex/dev
Improve Patch Option Style <3
2026-06-25 01:28:34 +10:00
meoware.exe 4486e52117 Improve Patch Option Style <3 2026-06-25 01:27:32 +10:00
meoware.exe bafd40a20c Merge pull request #54 from meowarex/dev
Advanced Options + New Patch Options <3
2026-06-25 01:05:04 +10:00
meoware.exe c89827c7b2 Advanced Options + New Patch Options <3 2026-06-25 01:03:28 +10:00
meoware.exe 90b49cc571 Merge pull request #49 from Se7en-dev/feat/mini-player-dynamic-color-patch
[PATCH] mini player dynamic bg color
2026-06-24 18:31:38 +10:00
meoware.exe 8e86c0d2e1 Merge branch 'dev' into feat/mini-player-dynamic-color-patch 2026-06-24 18:20:49 +10:00
meoware.exe 8ac9fb0db5 Merge pull request #50 from Se7en-dev/feat/mini-player-gestures-patch
[PATCH] mini player gestures patch
2026-06-24 18:05:05 +10:00
Se7en-dev 6ad6cb2fe0 fix(patch): update order and patches version 2026-06-24 01:04:45 +02:00
Se7en-dev a9611a1667 feat(patch): mini player dynamic bg color 2026-06-24 01:01:57 +02:00
Se7en-dev 276dd0be2e fix(miniplayer gestures): clip text on right side 2026-06-24 00:30:14 +02:00
Se7en-dev 3758ba19f3 feat(mini player gestures): track swipe feedback + fix stuck offsets 2026-06-23 23:08:02 +02:00
Se7en-dev 75450aad60 fix(mini player gestures patch): support redeisgned mp floating seek bar variant 2026-06-23 14:51:12 +02:00
Se7en-dev 3788eb2008 feat(patch): mini player gestures patch + patch suboptions 2026-06-23 14:18:38 +02:00
meoware.exe 95be099821 Merge pull request #48 from meowarex/dev
Overhaul Patch Defaults <3
2026-06-22 00:17:01 +10:00
meoware.exe 7a55787894 Bump Patch Version.. 2026-06-22 00:15:37 +10:00
meoware.exe bd09c2798e Overhaul Patch Defaults <3 2026-06-22 00:15:04 +10:00
meoware.exe b0b614dc6f Merge pull request #47 from meowarex/dev
New Patch: One-Handed Mode <3
2026-06-21 23:16:23 +10:00
meoware.exe 6a7d5fe90f Support for Animated Covers <3 2026-06-21 17:21:07 +10:00
meoware.exe 4791e69bec New Patch: One-Handed Mode <3 2026-06-21 01:36:05 +10:00
meoware.exe 7f3618aeae Fix Nav Bleed <3 2026-06-21 00:37:24 +10:00
meoware.exe ea9c6458c2 Update "Add Track" Icon <3 2026-06-20 23:53:33 +10:00
meoware.exe bdbcc4fc62 Merge pull request #44 from meowarex/dev
New Patch: Quality Badge Color
2026-06-20 23:06:36 +10:00
meoware.exe 3b893c12ff Renamed Patch File <3 2026-06-20 23:05:28 +10:00
meoware.exe 9d60e3dc80 Merge pull request #43 from Se7en-dev/feat/quality-badge-colors-patch
[PATCH] Quality Badge Colors
2026-06-20 22:55:24 +10:00
Se7en-dev 36382437f1 feat(patches): added quality badge colors patch 2026-06-19 17:29:19 +02:00
meoware.exe 5dc9188772 Merge pull request #41 from meowarex/dev
Mark RL API as WIP
2026-06-16 02:44:23 +10:00
meoware.exe 7823a54d11 Mark RL API as WIP 2026-06-16 02:43:35 +10:00
meoware.exe 4d5fea843e Merge pull request #40 from meowarex/dev
Rework Update Notification
2026-06-16 02:31:19 +10:00
meoware.exe d6bc889387 Rework Update Notification 2026-06-16 02:19:33 +10:00
242 changed files with 25451 additions and 1831 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
+17 -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",
)
@@ -228,3 +232,13 @@ fun ProviderFactory.execIgnoreCode(vararg command: String): String = try {
} catch (_: Exception) {
""
}
// Sync Local Cached Assets with Repo Releases to keep them up to date
val syncBundledManifest by tasks.registering(Copy::class) {
val src = rootProject.rootDir.resolve("../patches/manifest.json")
onlyIf { src.exists() }
from(src)
into(layout.projectDirectory.dir("src/main/assets"))
rename { "patches-manifest.json" }
}
tasks.named("preBuild") { dependsOn(syncBundledManifest) }
@@ -0,0 +1,658 @@
{
"version": 1,
"patches": [
{
"id": "EnableLegacyUi",
"order": 10,
"fileNames": [
"enable-legacy-ui.patch"
],
"title": "Enable Legacy UI",
"description": "[This patch will stop working soon] - Replaces the New Compose based UI with the Legacy UI (disables player-market-ui feature flag)",
"default": false,
"requires": [
"DebugMenuUnlock"
],
"disables": [
"LyricsRlApi",
"PlayerBackdrop",
"QualityBadgeColors",
"PlayerMovePlayingFrom",
"PlayerOneHanded",
"LyricsProgressPill",
"LyricsDisableCover",
"LyricsReplaceLyricsButton",
"LyricsReplaceShareButton",
"LyricsKeepControlsVisible"
]
},
{
"id": "LyricsRlApi",
"order": 20,
"fileNames": [
"lyrics-rl-api.patch",
"lyrics-rl-api-observer.patch",
"lyrics-rl-api-isrc.patch"
],
"title": "Radiant Lyrics API - WIP",
"description": "Use Radiant Lyrics API to fetch Lyrics (Syllable Level & Higher Quality)",
"default": false,
"defaultVariantIndex": 0,
"variants": [
{
"title": "Line",
"fileNames": []
},
{
"title": "Word",
"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"
}
]
},
{
"id": "PlayerBackdrop",
"order": 30,
"fileNames": [],
"title": "Player Backdrop",
"description": "Swap the player backdrop for the Kawarp shader, or fine-tune TIDAL's own blurred cover",
"default": true,
"defaultVariantIndex": 1,
"variants": [
{
"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": [
{
"type": "slider",
"key": "blur_strength",
"title": "Blur Strength",
"description": "The value of Blur applied to the backdrop cover",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_BLUR",
"encode": {
"kind": "int",
"scale": 1.0
},
"requiresVariant": 0
},
{
"type": "slider",
"key": "contrast",
"title": "Contrast",
"description": "The value of Contrast applied to the backdrop cover",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_CONTRAST",
"encode": {
"kind": "floatBits",
"scale": 0.02
},
"requiresVariant": 0
},
{
"type": "slider",
"key": "saturation",
"title": "Saturation",
"description": "The value of Saturation applied to the backdrop cover",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_SATURATION",
"encode": {
"kind": "floatBits",
"scale": 0.02
},
"requiresVariant": 0
},
{
"type": "slider",
"key": "brightness",
"title": "Brightness",
"description": "The value of Brightness applied to the backdrop cover",
"default": 50,
"min": 0,
"max": 100,
"steps": 19,
"displayAsPercent": true,
"token": "RL_BRIGHTNESS",
"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",
"key": "cover_everywhere",
"title": "Cover Everywhere - WIP",
"description": "Applies TIDAL's native blurred album-art backdrop to every Compose based page (Home & Collection)",
"default": false,
"inline": true,
"fileNames": [
"home-backdrop.patch",
"collection-backdrop.patch",
"cover-capture.patch"
]
}
]
},
{
"id": "QualityBadgeColors",
"order": 36,
"fileNames": [
"player-quality-badge-colors.patch"
],
"title": "Quality Badge Colors",
"description": "Brings color back to the player quality badge.",
"default": true
},
{
"id": "PlayerMovePlayingFrom",
"order": 37,
"fileNames": [
"player-move-playing-from.patch"
],
"extensionFiles": [
"radiant/PlayingFrom.smali"
],
"title": "Playing From",
"description": "Shows the source name (playlist/album/mix) above the track title",
"default": true
},
{
"id": "PlayerOneHanded",
"order": 38,
"fileNames": [
"player-one-handed.patch"
],
"title": "One-Handed Mode",
"description": "Mirrors the bottom player buttons left-to-right for easier one-handed reach.",
"default": false
},
{
"id": "LyricsProgressPill",
"order": 40,
"fileNames": [
"lyrics-progress-pill.patch",
"lyrics-fade-region.patch"
],
"title": "Lyrics Progress Pill",
"description": "Restores the old Track Progress Pill in the top of the Lyrics screen!",
"default": true,
"requires": [
"LyricsDisableCover",
"LyricsReplaceLyricsButton",
"LyricsReplaceShareButton"
]
},
{
"id": "LyricsDisableCover",
"order": 41,
"fileNames": [
"lyrics-disable-cover.patch"
],
"title": "Disable Lyrics Cover",
"description": "Removes the mini track cover from the lyrics screen.",
"default": true
},
{
"id": "LyricsReplaceLyricsButton",
"order": 42,
"fileNames": [
"lyrics-replace-lyrics-button.patch",
"lyrics-sparkle-conditional-visibility.patch"
],
"title": "Replace Lyrics Button",
"description": "Replaces the Lyrics button with the RL Sparkle!",
"default": true
},
{
"id": "LyricsReplaceShareButton",
"order": 43,
"fileNames": [
"lyrics-replace-share-button.patch"
],
"title": "Replace Share Button",
"description": "Replaces the share button with the Tidal Connect button from the top of the screen. (it's in the menu already)",
"default": true
},
{
"id": "MiniPlayerRedesign",
"order": 50,
"fileNames": [],
"title": "Redesigned Mini-Player",
"description": "Integrates the Seekbar into the control panel at the bottom of the screen.",
"default": false,
"defaultVariantIndex": 2,
"variants": [
{
"title": "Floating",
"fileNames": []
},
{
"title": "Grey",
"fileNames": [
"mini-player-grey.patch"
]
},
{
"title": "Black",
"fileNames": [
"mini-player-black.patch"
]
}
],
"advancedOptions": [
{
"type": "toggle",
"key": "dynamic_bg",
"title": "Mini-Player Dynamic Background",
"description": "Colorizes the Mini-Player depending on the currently playing track.",
"default": false,
"inline": true,
"fileNames": [
"mini-player-dynamic-bg.patch"
],
"extensionFiles": [
"radiant/MiniPlayerBackground.smali"
],
"hidesVariants": [
2
],
"relabelVariants": {
"1": "Legacy"
}
},
{
"type": "toggle",
"key": "border",
"title": "Progress Border",
"description": "Draws an animated progress ring around the floating mini-player. Requires the Floating style.",
"default": false,
"requiresVariant": 0,
"fileNames": [
"mini-player-floating-border.patch"
],
"extensionFiles": [
"radiant/MiniSeekerFloating.smali"
]
},
{
"type": "toggle",
"key": "gestures",
"title": "Mini-Player Gestures",
"description": "Adds swipe up gesture to the mini-player to reopen the player",
"default": true,
"fileNames": [
"mini-player-up-gesture.patch",
"player-swipe-gesture.patch",
"lyrics-gesture-scroll-guard.patch"
],
"extensionFiles": [
"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"
]
},
{
"type": "choice",
"key": "swipe_up_drag",
"title": "Swipe Up Style",
"entries": [
"Static",
"Drag"
],
"values": [
"0x0",
"0x1"
],
"defaultIndex": 1,
"requiresOption": "gestures",
"token": "RL_MINI_PLAYER_SWIPE_UP_DRAG"
},
{
"type": "toggle",
"key": "next_prev",
"title": "Next/Previous Gestures",
"description": "Swipe left or right on the mini-player to skip tracks.",
"default": false,
"requiresOption": "gestures",
"fileNames": [
"mini-player-left-right-gesture.patch"
],
"extensionFiles": [
"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"
]
}
]
},
{
"id": "LyricsKeepControlsVisible",
"order": 60,
"fileNames": [
"lyrics-keep-controls-visible.patch"
],
"extensionFiles": [
"radiant/LyricsReinitGuard.smali"
],
"title": "Keep Controls Visible",
"description": "Inverts the auto-hide behavior on the lyrics screen, playback controls stay visible by default and only hide when you tap them off. Also stops the open lyrics view from randomly closing when the player re-fetches the same track.",
"default": true
},
{
"id": "WazeIntegration",
"order": 90,
"category": "integration",
"pathLocked": true,
"fileNames": [
"integration-waze-playback.patch"
],
"extensionFiles": [
"radiant/WazeInitReceiver.smali",
"radiant/WazeServiceConnection.smali"
],
"title": "Waze Integration",
"description": "Control TIDAL playback from the Waze app",
"default": true,
"advancedOptions": [
{
"type": "choice",
"key": "browse_root",
"title": "Media Menu",
"entries": [
"Auto Menu",
"Home",
"Recents"
],
"values": [
"ROOT_AUTO",
"HOME_V2::home_page_v2_id",
"RECENTLY_PLAYED::home/pages/CONTINUE_LISTEN_TO/view-all"
],
"defaultIndex": 0,
"token": "RL_WAZE_ROOT_ID"
},
{
"type": "color",
"key": "accent_color",
"title": "Accent Color",
"default": -16747037,
"token": "RL_WAZE_THEME_COLOR"
}
]
},
{
"id": "DebugMenuUnlock",
"order": 100,
"fileNames": [
"debug-menu-unlock.patch"
],
"title": "Unlock Debug Menu",
"description": "Reveals TIDAL's hidden Debug Menu in Settings. (Unlocks Feature Flags & the legacy Debug Options)",
"default": false
}
]
}
@@ -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 {
@@ -22,7 +22,8 @@ class PathManager(
val patchingDownloadDir = patchingDir.resolve("downloads")
val cacheDownloadDir = context.cacheDir.resolve("downloads")
// Persistent Stash of assets for Offline Repatching
val cacheDownloadDir = patchingDir.resolve("downloads-cache")
val customComponentsDir = patchingDir.resolve("custom")
@@ -34,11 +35,15 @@ class PathManager(
val patchedApk = patchingWorkingDir.resolve("patched.apk")
// Persistent snapshot of the last successful release fetch (build info + patches asset URL)
val cachedReleaseInfo = patchingDir.resolve("release-info.json")
fun clearCache() {
val targets = arrayOf(
patchingDownloadDir,
patchingWorkingDir,
cacheDownloadDir,
cachedReleaseInfo,
context.cacheDir,
)
for (dir in targets) {
@@ -54,6 +59,16 @@ class PathManager(
.resolve("patches")
.resolve("$version.zip")
fun hasCachedTidalApk(version: Int, split: String = "base"): Boolean {
val rel = "tidal/$version/$split.apk"
return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists()
}
fun hasCachedSmaliPatches(version: SemVer): Boolean {
val rel = "patches/$version.zip"
return patchingDownloadDir.resolve(rel).exists() || cacheDownloadDir.resolve(rel).exists()
}
fun customTidalApks() = customTidalApksDir.listFiles()?.asList() ?: emptyList()
fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList()
@@ -4,14 +4,19 @@ 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)
// Dev: force-apply path-gated ("incompatible") patches even under a non-stock package name
var bypassIncompatible by booleanPreference("bypass_incompatible", false)
var showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
var autoUpdateCheck by booleanPreference("auto_update_check", true)
@@ -0,0 +1,32 @@
package com.meowarex.rlmobile.manager
import android.util.Log
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.network.models.RLBuildInfo
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// Snapshot of the last successful release fetch (build info + patches asset URL)
@Serializable
data class CachedReleaseInfo(
val data: RLBuildInfo,
val patchesAssetUrl: String,
)
object ReleaseInfoCache {
fun load(paths: PathManager, json: Json): CachedReleaseInfo? = try {
paths.cachedReleaseInfo
.takeIf { it.exists() }
?.let { json.decodeFromString<CachedReleaseInfo>(it.readText()) }
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to read cached release info", t)
null
}
fun save(paths: PathManager, json: Json, info: CachedReleaseInfo) = try {
paths.cachedReleaseInfo.parentFile?.mkdirs()
paths.cachedReleaseInfo.writeText(json.encodeToString(info))
} catch (t: Throwable) {
Log.w(BuildConfig.TAG, "Failed to cache release info", t)
}
}
@@ -26,11 +26,16 @@ class PatchManifestStep(private val options: PatchOptions) : Step() {
?: throw IllegalArgumentException("No manifest found in APK")
container.log("Patching manifest")
val wazeDefault = container.getStep<SmaliPatchStep>().specs
.firstOrNull { it.id == "WazeIntegration" }?.defaultEnabled ?: false
val enableWazeIntegration = options.patchStates["WazeIntegration"] ?: wazeDefault
val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest,
packageName = options.packageName,
appName = options.appName,
debuggable = options.debuggable,
enableWazeIntegration = enableWazeIntegration,
)
container.log("Repacking apk with patched manifest")
@@ -1,5 +1,6 @@
package com.meowarex.rlmobile.patcher.steps.patch
import android.content.Context
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.patcher.StepRunner
@@ -8,7 +9,10 @@ import com.meowarex.rlmobile.patcher.steps.base.IDexProvider
import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep
import com.meowarex.rlmobile.ui.screens.patchopts.PatchManifest
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.patchopts.PatchSpec
import com.meowarex.rlmobile.ui.screens.patchopts.builtinPatchSpecs
import com.android.tools.smali.baksmali.Baksmali
import com.android.tools.smali.baksmali.BaksmaliOptions
import com.android.tools.smali.dexlib2.Opcodes
@@ -19,6 +23,7 @@ import com.github.diamondminer88.zip.ZipReader
import com.github.difflib.DiffUtils
import com.github.difflib.UnifiedDiffUtils
import com.github.difflib.patch.Patch
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.*
@@ -27,6 +32,11 @@ class SmaliPatchStep(
private val options: PatchOptions,
) : Step(), IDexProvider, KoinComponent {
private val paths: PathManager by inject()
private val json: Json by inject()
private val context: Context by inject()
var specs: List<PatchSpec> = emptyList()
private set
override val group = StepGroup.Patch
override val localizedName = R.string.patch_step_patch_smali
@@ -42,6 +52,29 @@ class SmaliPatchStep(
val patches = mutableListOf<LoadedPatch>()
val localsBumps = mutableMapOf<Pair<String, String>, Int>()
// The patch list/options metadata lives in the zip's manifest.json
val manifestSpecs = try {
ZipReader(patchesZip).use { it.openEntry("manifest.json")?.read() }
?.let { json.decodeFromString(PatchManifest.serializer(), it.decodeToString()).patches }
?.takeIf { it.isNotEmpty() }
} catch (t: Throwable) {
container.log("Failed to parse manifest.json (${t.message}); using built-in patch specs")
null
}
specs = manifestSpecs ?: builtinPatchSpecs(context, json)
container.log(
"Loaded ${specs.size} patch specs from " +
if (manifestSpecs != null) "manifest.json" else "built-in list"
)
val disabledFiles = options.disabledPatchFiles(specs)
val knownExtensionFiles = options.knownExtensionFiles(specs)
val enabledExtensionFiles = options.enabledExtensionFiles(specs)
val substitutions = options.smaliSubstitutions(specs)
if (substitutions.isNotEmpty()) {
container.log("Patch option substitutions: ${substitutions.entries.joinToString { "${it.key}=${it.value}" }}")
}
// Load and parse all the patches from the smali archive.
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
smaliDir.mkdirs()
@@ -53,6 +86,11 @@ class SmaliPatchStep(
if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) {
val relative = patchFile.removePrefix("extension/")
// Only bundle helper smali patch/variant/sub-option is enabled
if (relative in knownExtensionFiles && relative !in enabledExtensionFiles) {
container.log("Skipping disabled extension smali: $relative")
continue
}
val out = smaliDir.resolve(relative)
// Guard against zip-slip: a crafted entry could otherwise escape smaliDir.
val baseCanonical = smaliDir.canonicalPath + File.separator
@@ -63,7 +101,19 @@ class SmaliPatchStep(
val entry = zip.openEntry(patchFile)
?: throw FileNotFoundException("Missing zip entry: $patchFile")
out.canonicalFile.parentFile?.mkdirs()
out.writeBytes(entry.read())
var smaliText = entry.read()
.decodeToString()
.replace("\r\n", "\n")
for ((token, value) in substitutions) {
if (smaliText.contains(token)) {
smaliText = smaliText.replace(token, value)
container.log("Applied substitution $token -> $value in $patchFile")
}
}
UNRESOLVED_TOKEN.find(smaliText)?.let { match ->
throw Error("Unresolved option placeholder ${match.value} in $patchFile")
}
out.writeText(smaliText)
container.log("Extracted extension smali: $relative")
continue
}
@@ -71,16 +121,39 @@ class SmaliPatchStep(
if (!patchFile.endsWith(".patch")) continue
val basename = patchFile.substringAfterLast('/')
if (basename in options.disabledPatches) {
if (basename in disabledFiles) {
container.log("Skipping disabled patch $patchFile")
continue
}
val lines = zip.openEntry(patchFile)!!.read()
var patchText = zip.openEntry(patchFile)!!.read()
.decodeToString()
.replace("\r\n", "\n") // Replace CRLF endings with LF
.trimEnd { it == '\n' } // Remove trailing new lines
.split('\n')
// Bake advanced option values into the patch
for ((token, value) in substitutions) {
if (patchText.contains(token)) {
patchText = patchText.replace(token, value)
container.log("Applied substitution $token -> $value in $patchFile")
}
}
// Fail fast
UNRESOLVED_TOKEN.find(patchText)?.let { match ->
throw Error("Unresolved option placeholder ${match.value} in $patchFile")
}
// Skip a patch that references a helper class which won't be bundled
val missingHelper = (knownExtensionFiles - enabledExtensionFiles).firstOrNull { rel ->
patchText.contains("L${rel.removeSuffix(".smali")};")
}
if (missingHelper != null) {
container.log("Skipping $patchFile: references gated-off helper class $missingHelper")
continue
}
val lines = patchText.split('\n')
try {
for (directive in lines) {
@@ -320,6 +393,7 @@ class SmaliPatchStep(
private companion object {
val LOCALS_DIRECTIVE = Regex("""^#\s*rl-locals:\s+(\S+)\s+(\S+)\s+(\d+)\s*$""")
val UNRESOLVED_TOKEN = Regex("""__RL_[A-Z0-9_]+__""")
}
/**
@@ -1,19 +1,30 @@
package com.meowarex.rlmobile.patcher.steps.prepare
import android.app.Application
import android.util.Log
import androidx.compose.runtime.Stable
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.CachedReleaseInfo
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.manager.ReleaseInfoCache
import com.meowarex.rlmobile.network.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.getOrThrow
import com.meowarex.rlmobile.patcher.StepRunner
import com.meowarex.rlmobile.patcher.steps.StepGroup
import com.meowarex.rlmobile.patcher.steps.base.Step
import com.meowarex.rlmobile.util.isOnline
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.cancellation.CancellationException
@Stable
class FetchInfoStep : Step(), KoinComponent {
private val github: RadiantLyricsGithubService by inject()
private val context: Application by inject()
private val paths: PathManager by inject()
private val json: Json by inject()
override val group = StepGroup.Prepare
override val localizedName = R.string.patch_step_fetch_info
@@ -25,6 +36,33 @@ class FetchInfoStep : Step(), KoinComponent {
private set
override suspend fun execute(container: StepRunner) {
// Fetch the latest release and refresh the cache (when online)
if (context.isOnline()) {
try {
fetchFromRemote(container)
return
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
container.log("Failed to fetch latest release: ${Log.getStackTraceString(t)}")
container.log("Falling back to cached release info for a local re-patch")
}
} else {
container.log("No network connection — using cached release info for a local re-patch")
}
val cached = ReleaseInfoCache.load(paths, json)
?: throw IllegalStateException(
"Could not fetch the latest release and no cached release info is available. " +
"Connect to the internet and try again."
)
data = cached.data
patchesAssetUrl = cached.patchesAssetUrl
container.log("Using cached build info: $data")
container.log("Cached patches asset URL: $patchesAssetUrl")
}
private suspend fun fetchFromRemote(container: StepRunner) {
container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}")
val release = github.getLatestRelease(force = true).getOrThrow()
@@ -33,14 +71,19 @@ class FetchInfoStep : Step(), KoinComponent {
?.browserDownloadUrl
?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}")
patchesAssetUrl = release.assets
val patchesUrl = release.assets
.find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME }
?.browserDownloadUrl
?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}")
container.log("Fetching build info from $dataJsonUrl")
data = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow()
val buildInfo = github.getBuildInfo(dataJsonUrl, force = true).getOrThrow()
data = buildInfo
patchesAssetUrl = patchesUrl
container.log("Fetched build info: $data")
container.log("Patches asset URL: $patchesAssetUrl")
ReleaseInfoCache.save(paths, json, CachedReleaseInfo(buildInfo, patchesUrl))
}
}
@@ -18,12 +18,16 @@ object ManifestPatcher {
private const val IS_SPLIT_REQUIRED = "isSplitRequired"
private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes"
private const val SPLIT_TYPES = "splitTypes"
private const val WAZE_PACKAGE = "com.waze"
private const val WAZE_INIT_RECEIVER = "radiant.WazeInitReceiver"
private const val WAZE_ACTION_INIT = "com.waze.sdk.audio.ACTION_INIT"
fun patchManifest(
manifestBytes: ByteArray,
packageName: String,
appName: String,
debuggable: Boolean,
enableWazeIntegration: Boolean,
): ByteArray {
// Extract original package name to rewrite every reference to it
// (permissions, provider authorities) to the new packageName.
@@ -84,6 +88,49 @@ object ManifestPatcher {
}
return when (name) {
"queries" -> object : NodeVisitor(nv) {
private var hasWazePackage = false
override fun child(ns: String?, name: String): NodeVisitor {
val visitor = super.child(ns, name)
return if (enableWazeIntegration && name == "package") {
object : NodeVisitor(visitor) {
override fun attr(
ns: String?,
name: String?,
resourceId: Int,
type: Int,
value: Any?,
) {
if (name == "name" && value == WAZE_PACKAGE) {
hasWazePackage = true
}
super.attr(ns, name, resourceId, type, value)
}
}
} else {
visitor
}
}
override fun end() {
if (enableWazeIntegration && !hasWazePackage) {
super.child(null, "package").apply {
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
WAZE_PACKAGE,
)
end()
}
}
super.end()
}
}
"uses-permission" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name != "maxSdkVersion") {
@@ -132,6 +179,7 @@ object ManifestPatcher {
private var addUseEmbeddedDex = true
private var addExtractNativeLibs = true
private var addMetadata = true
private var hasWazeInitReceiver = false
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false
@@ -161,6 +209,22 @@ object ManifestPatcher {
return when (name) {
"activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName))
"receiver" -> object : NodeVisitor(visitor) {
override fun attr(
ns: String?,
name: String,
resourceId: Int,
type: Int,
value: Any?
) {
if (name == "name" && value == WAZE_INIT_RECEIVER) {
hasWazeInitReceiver = true
}
super.attr(ns, name, resourceId, type, value)
}
}
"provider" -> object : NodeVisitor(visitor) {
override fun attr(
ns: String?,
@@ -219,6 +283,39 @@ object ManifestPatcher {
0
)
if (enableWazeIntegration && !hasWazeInitReceiver) {
super.child(null, "receiver").apply {
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
WAZE_INIT_RECEIVER,
)
attr(
ANDROID_NAMESPACE,
"exported",
android.R.attr.exported,
TYPE_INT_BOOLEAN,
1,
)
child(null, "intent-filter").apply {
child(null, "action").apply {
attr(
ANDROID_NAMESPACE,
"name",
android.R.attr.name,
TYPE_STRING,
WAZE_ACTION_INIT,
)
end()
}
end()
}
end()
}
}
super.end()
}
}
@@ -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,
),
)
}
@@ -2,11 +2,14 @@ package com.meowarex.rlmobile.ui.previews.screens
import android.content.res.Configuration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.screens.patchopts.*
import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlinx.serialization.json.Json
import kotlin.time.Clock
// This preview has scrollable/interactable content that cannot be tested from an IDE preview
@@ -18,28 +21,37 @@ private fun PatchOptionsScreenPreview(
@PreviewParameter(PatchOptionsParametersProvider::class)
parameters: PatchOptionsParameters,
) {
val context = LocalContext.current
val specs = remember { builtinPatchSpecs(context, Json { ignoreUnknownKeys = true }) }
ManagerTheme {
PatchOptionsScreenContent(
isUpdate = parameters.isUpdate,
isDevMode = parameters.isDevMode,
debuggable = parameters.debuggable,
setDebuggable = {},
bypassIncompatible = false,
setBypassIncompatible = {},
appName = parameters.appName,
appNameIsError = parameters.appNameIsError,
setAppName = {},
packageName = parameters.packageName,
packageNameState = parameters.packageNameState,
setPackageName = {},
packageNameLocked = parameters.packageName == PatchOptions.Default.packageName,
onUnlockPackageName = {},
onLockPackageName = {},
customTidalApk = parameters.customTidalApk,
onSelectCustomTidalApk = {},
customPatches = parameters.customPatches,
onSelectCustomPatches = {},
enabledPatchCount = KnownPatch.All.size,
specs = specs,
isPatchEnabled = { true },
onTogglePatch = { _, _ -> },
patchLockState = { PatchLock.Free },
variantIndex = { 0 },
onSelectVariant = { _, _ -> },
optionState = PatchOptionState.Preview,
isConfigValid = parameters.isConfigValid,
onInstall = {},
)
@@ -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,
@@ -19,7 +19,9 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.manager.PreferencesManager
import com.meowarex.rlmobile.manager.ReleaseInfoCache
import com.meowarex.rlmobile.network.models.GithubCommit
import com.meowarex.rlmobile.network.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
@@ -42,6 +44,7 @@ class HomeModel(
private val github: RadiantLyricsGithubService,
private val json: Json,
private val prefs: PreferencesManager,
private val paths: PathManager,
) : ScreenModel {
var state by mutableStateOf<HomeState>(HomeState.Loading)
@@ -170,16 +173,23 @@ class HomeModel(
refreshingLock.withLock {
val pkg = fetchInstalled()
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
remote.await()
// Skip the remote fetch entirely when offline
val online = application.isOnline()
if (online) {
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
remote.await()
}
val install = pkg?.toInstallData()
val latest = remoteDataJson?.tidalVersionCode
val offlineRepatchReady = hasOfflineRepatchAssets()
mainThread {
state = HomeState.Loaded(
install = install,
latestTidalVersionCode = latest,
offline = !online,
offlineRepatchReady = offlineRepatchReady,
)
maybeCheckManagerUpdate(pkg)
}
@@ -201,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) {
@@ -218,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)
@@ -257,6 +276,13 @@ class HomeModel(
)
}
// check if an offline "Local Repatch" is possible
private fun hasOfflineRepatchAssets(): Boolean {
val info = ReleaseInfoCache.load(paths, json) ?: return false
return paths.hasCachedTidalApk(info.data.tidalVersionCode) &&
paths.hasCachedSmaliPatches(info.data.patchesVersion)
}
private suspend fun fetchRemoteData() {
val release = try {
github.getLatestRelease().fold(
@@ -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,96 +259,108 @@ private fun ColumnScope.HomeContent(
} else null
val iconPainter = install?.icon ?: fallbackPainter
if (iconPainter != null) {
Image(
painter = iconPainter,
contentDescription = null,
val hero = radiantStyle.heroCard
// 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
.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)
Button(
onClick = if (install == null) onInstall else onRepatch,
enabled = state.latestTidalVersionCode != null && !blockedByManagerUpdate,
modifier = Modifier.fillMaxWidth(),
) {
val label = when {
blockedByManagerUpdate -> "Manager Update Required"
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(),
)
}
AnimatedVisibility(visible = install != null) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.clip(RoundedCornerShape(16.dp)),
.fillMaxWidth()
.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()),
)
}
ElevatedCard(modifier = Modifier.fillMaxSize()) {
CommitList(commits = commits.collectAsLazyPagingItems())
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),
)
}
}
}
}
}
/**
* 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)
}
}
@@ -8,5 +8,7 @@ sealed interface HomeState {
data class Loaded(
val install: InstallData?,
val latestTidalVersionCode: Int?,
val offline: Boolean = false,
val offlineRepatchReady: Boolean = false,
) : HomeState
}
@@ -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,
)
}
)
}

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