Compare commits

...
78 Commits
Author SHA1 Message Date
meowarex bbd0876324 Merge pull request #87 from meowarex/dev
Refactor Backdrop <3
2026-08-14 16:55:38 +00:00
meowarex 5d8e52ce9f Refactor Backdrop <3 2026-08-14 16:55:27 +00:00
meoware.exe 5e150d10e3 Merge pull request #86 from meowarex/dev
New Shader Defaults <3
2026-08-15 00:23:45 +10:00
meowarex 79ea2e512d New Shader Defaults <3 2026-08-14 14:13:44 +00:00
meoware.exe 225ebe3413 Merge pull request #85 from meowarex/dev
Update License <3
2026-08-14 21:08:56 +10:00
meowarex abcbd10678 Update License <3 2026-08-14 08:28:02 +00:00
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
245 changed files with 25947 additions and 1881 deletions
+17 -1
View File
@@ -6,6 +6,8 @@ on:
- main - main
paths-ignore: paths-ignore:
- "**/*.md" - "**/*.md"
# Lets a release be re-run by hand from the Actions tab if a push event is ever missed.
workflow_dispatch:
jobs: jobs:
Version: Version:
@@ -16,8 +18,22 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- id: set - id: set
run: | 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:]') BASE=$(cat .version | tr -d '[:space:]')
echo "version=$BASE.${{ github.run_number }}" >> $GITHUB_OUTPUT echo "version=$BASE" >> $GITHUB_OUTPUT
# Releasing onto an existing tag silently overwrites that release's assets, so a
# forgotten .version bump replaces history instead of publishing. Fail loudly instead.
# ls-remote, not rev-parse: actions/checkout does not fetch tags by default,
# so a local lookup would always miss and the guard would never fire.
if git ls-remote --exit-code --tags origin "refs/tags/v$BASE" >/dev/null 2>&1; then
echo "::error::Tag v$BASE already exists - bump .version before releasing." \
"Re-running this workflow as-is would overwrite the v$BASE release assets."
exit 1
fi
Build: Build:
needs: Version needs: Version
+3
View File
@@ -27,3 +27,6 @@ Reference/
!tidal-apk/*.apk !tidal-apk/*.apk
!tidal-apk/*.apkm !tidal-apk/*.apkm
tidal-apk/*/ 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.4
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+17 -3
View File
@@ -23,13 +23,16 @@ val gitHasHasLocalChanges = providers.execIgnoreCode("git", "status", "-s").isNo
android { android {
namespace = "com.meowarex.rlmobile" 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 { defaultConfig {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 10_03_00 versionCode = 66
versionName = "1.3.0" versionName = "1.1.4"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -161,6 +164,7 @@ kotlin {
"androidx.compose.foundation.ExperimentalFoundationApi", "androidx.compose.foundation.ExperimentalFoundationApi",
"androidx.compose.foundation.layout.ExperimentalLayoutApi", "androidx.compose.foundation.layout.ExperimentalLayoutApi",
"androidx.compose.material3.ExperimentalMaterial3Api", "androidx.compose.material3.ExperimentalMaterial3Api",
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.time.ExperimentalTime", "kotlin.time.ExperimentalTime",
"kotlinx.serialization.ExperimentalSerializationApi", "kotlinx.serialization.ExperimentalSerializationApi",
) )
@@ -228,3 +232,13 @@ fun ProviderFactory.execIgnoreCode(vararg command: String): String = try {
} catch (_: Exception) { } 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": 6,
"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": 100,
"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": 100,
"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.manager.*
import com.meowarex.rlmobile.patcher.InstallMetadata import com.meowarex.rlmobile.patcher.InstallMetadata
import com.meowarex.rlmobile.ui.screens.home.HomeScreen 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.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.patchopts.PatchOptions
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel import com.meowarex.rlmobile.ui.screens.permissions.PermissionsModel
import com.meowarex.rlmobile.ui.screens.permissions.PermissionsScreen 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.ManagerTheme
import com.meowarex.rlmobile.ui.theme.UiStyle
import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog import com.meowarex.rlmobile.ui.widgets.updater.UpdaterDialog
import com.meowarex.rlmobile.util.* import com.meowarex.rlmobile.util.*
import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipReader
@@ -63,6 +67,7 @@ class MainActivity : ComponentActivity() {
ManagerTheme( ManagerTheme(
theme = preferences.theme, theme = preferences.theme,
uiStyle = preferences.uiStyle,
dynamicColor = preferences.dynamicColor, dynamicColor = preferences.dynamicColor,
) { ) {
if (BuildConfig.RELEASE) { if (BuildConfig.RELEASE) {
@@ -73,14 +78,20 @@ class MainActivity : ComponentActivity() {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavigatorSaver provides parcelableNavigatorSaver(), LocalNavigatorSaver provides parcelableNavigatorSaver(),
) { ) {
val legacyUi = preferences.uiStyle == UiStyle.Legacy
key(legacyUi) {
Navigator( Navigator(
screen = HomeScreen(), screen = if (legacyUi) LegacyHomeScreen() else HomeScreen(),
onBackPressed = null, onBackPressed = null,
) { navigator -> ) { navigator ->
// Open the permissions screen whenever permissions are insufficient // Open the permissions screen whenever permissions are insufficient
LaunchedEffect(permissions.requiredPermsGranted) { LaunchedEffect(permissions.requiredPermsGranted) {
if (!permissions.requiredPermsGranted) if (!permissions.requiredPermsGranted) {
navigator.pushOnce(PermissionsScreen()) navigator.pushOnce(
if (legacyUi) LegacyPermissionsScreen() else PermissionsScreen()
)
}
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -104,6 +115,7 @@ class MainActivity : ComponentActivity() {
) )
) )
} }
}
overlays.Overlays() overlays.Overlays()
} }
@@ -184,7 +196,11 @@ class MainActivity : ComponentActivity() {
val patchOptions = metadata?.options val patchOptions = metadata?.options
?: PatchOptions.Default.copy(packageName = packageName) ?: PatchOptions.Default.copy(packageName = packageName)
return PatchingScreen(patchOptions) return if (preferences.uiStyle == UiStyle.Legacy) {
LegacyPatchingScreen(patchOptions)
} else {
PatchingScreen(patchOptions)
}
} }
companion object { companion object {
@@ -22,7 +22,8 @@ class PathManager(
val patchingDownloadDir = patchingDir.resolve("downloads") 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") val customComponentsDir = patchingDir.resolve("custom")
@@ -34,11 +35,15 @@ class PathManager(
val patchedApk = patchingWorkingDir.resolve("patched.apk") 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() { fun clearCache() {
val targets = arrayOf( val targets = arrayOf(
patchingDownloadDir, patchingDownloadDir,
patchingWorkingDir, patchingWorkingDir,
cacheDownloadDir, cacheDownloadDir,
cachedReleaseInfo,
context.cacheDir, context.cacheDir,
) )
for (dir in targets) { for (dir in targets) {
@@ -54,6 +59,16 @@ class PathManager(
.resolve("patches") .resolve("patches")
.resolve("$version.zip") .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 customTidalApks() = customTidalApksDir.listFiles()?.asList() ?: emptyList()
fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList() fun customSmaliPatches() = customPatchesDir.listFiles()?.asList() ?: emptyList()
@@ -4,14 +4,19 @@ import android.content.SharedPreferences
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.meowarex.rlmobile.manager.base.BasePreferenceManager import com.meowarex.rlmobile.manager.base.BasePreferenceManager
import com.meowarex.rlmobile.ui.theme.Theme import com.meowarex.rlmobile.ui.theme.Theme
import com.meowarex.rlmobile.ui.theme.UiStyle
@Stable @Stable
class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) { class PreferencesManager(preferences: SharedPreferences) : BasePreferenceManager(preferences) {
var theme by enumPreference("theme", Theme.System) 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 devMode by booleanPreference("dev_mode", false)
var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller) var installer by enumPreference<InstallerSetting>("installer", InstallerSetting.PackageInstaller)
var keepPatchedApks by booleanPreference("keep_patched_apks", false) 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 showPlayProtectWarning by booleanPreference("show_play_protect_warning", true)
var autoUpdateCheck by booleanPreference("auto_update_check", 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") ?: throw IllegalArgumentException("No manifest found in APK")
container.log("Patching manifest") 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( val patchedManifest = ManifestPatcher.patchManifest(
manifestBytes = manifest, manifestBytes = manifest,
packageName = options.packageName, packageName = options.packageName,
appName = options.appName, appName = options.appName,
debuggable = options.debuggable, debuggable = options.debuggable,
enableWazeIntegration = enableWazeIntegration,
) )
container.log("Repacking apk with patched manifest") container.log("Repacking apk with patched manifest")
@@ -1,5 +1,6 @@
package com.meowarex.rlmobile.patcher.steps.patch package com.meowarex.rlmobile.patcher.steps.patch
import android.content.Context
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PathManager import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.patcher.StepRunner 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.base.Step
import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep import com.meowarex.rlmobile.patcher.steps.download.CopyDependenciesStep
import com.meowarex.rlmobile.patcher.steps.download.DownloadPatchesStep 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.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.Baksmali
import com.android.tools.smali.baksmali.BaksmaliOptions import com.android.tools.smali.baksmali.BaksmaliOptions
import com.android.tools.smali.dexlib2.Opcodes 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.DiffUtils
import com.github.difflib.UnifiedDiffUtils import com.github.difflib.UnifiedDiffUtils
import com.github.difflib.patch.Patch import com.github.difflib.patch.Patch
import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.* import java.io.*
@@ -27,6 +32,11 @@ class SmaliPatchStep(
private val options: PatchOptions, private val options: PatchOptions,
) : Step(), IDexProvider, KoinComponent { ) : Step(), IDexProvider, KoinComponent {
private val paths: PathManager by inject() 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 group = StepGroup.Patch
override val localizedName = R.string.patch_step_patch_smali override val localizedName = R.string.patch_step_patch_smali
@@ -42,6 +52,29 @@ class SmaliPatchStep(
val patches = mutableListOf<LoadedPatch>() val patches = mutableListOf<LoadedPatch>()
val localsBumps = mutableMapOf<Pair<String, String>, Int>() 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. // Load and parse all the patches from the smali archive.
container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}") container.log("Loading patches from smali patch archive: ${patchesZip.absolutePath}")
smaliDir.mkdirs() smaliDir.mkdirs()
@@ -53,6 +86,11 @@ class SmaliPatchStep(
if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) { if (patchFile.endsWith(".smali") && patchFile.startsWith("extension/")) {
val relative = patchFile.removePrefix("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) val out = smaliDir.resolve(relative)
// Guard against zip-slip: a crafted entry could otherwise escape smaliDir. // Guard against zip-slip: a crafted entry could otherwise escape smaliDir.
val baseCanonical = smaliDir.canonicalPath + File.separator val baseCanonical = smaliDir.canonicalPath + File.separator
@@ -63,7 +101,19 @@ class SmaliPatchStep(
val entry = zip.openEntry(patchFile) val entry = zip.openEntry(patchFile)
?: throw FileNotFoundException("Missing zip entry: $patchFile") ?: throw FileNotFoundException("Missing zip entry: $patchFile")
out.canonicalFile.parentFile?.mkdirs() 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") container.log("Extracted extension smali: $relative")
continue continue
} }
@@ -71,16 +121,39 @@ class SmaliPatchStep(
if (!patchFile.endsWith(".patch")) continue if (!patchFile.endsWith(".patch")) continue
val basename = patchFile.substringAfterLast('/') val basename = patchFile.substringAfterLast('/')
if (basename in options.disabledPatches) { if (basename in disabledFiles) {
container.log("Skipping disabled patch $patchFile") container.log("Skipping disabled patch $patchFile")
continue continue
} }
val lines = zip.openEntry(patchFile)!!.read() var patchText = zip.openEntry(patchFile)!!.read()
.decodeToString() .decodeToString()
.replace("\r\n", "\n") // Replace CRLF endings with LF .replace("\r\n", "\n") // Replace CRLF endings with LF
.trimEnd { it == '\n' } // Remove trailing new lines .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 { try {
for (directive in lines) { for (directive in lines) {
@@ -320,6 +393,7 @@ class SmaliPatchStep(
private companion object { private companion object {
val LOCALS_DIRECTIVE = Regex("""^#\s*rl-locals:\s+(\S+)\s+(\S+)\s+(\d+)\s*$""") 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 package com.meowarex.rlmobile.patcher.steps.prepare
import android.app.Application
import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.meowarex.rlmobile.R 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.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
import com.meowarex.rlmobile.network.utils.getOrThrow import com.meowarex.rlmobile.network.utils.getOrThrow
import com.meowarex.rlmobile.patcher.StepRunner import com.meowarex.rlmobile.patcher.StepRunner
import com.meowarex.rlmobile.patcher.steps.StepGroup import com.meowarex.rlmobile.patcher.steps.StepGroup
import com.meowarex.rlmobile.patcher.steps.base.Step 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.coroutines.cancellation.CancellationException
@Stable @Stable
class FetchInfoStep : Step(), KoinComponent { class FetchInfoStep : Step(), KoinComponent {
private val github: RadiantLyricsGithubService by inject() 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 group = StepGroup.Prepare
override val localizedName = R.string.patch_step_fetch_info override val localizedName = R.string.patch_step_fetch_info
@@ -25,6 +36,33 @@ class FetchInfoStep : Step(), KoinComponent {
private set private set
override suspend fun execute(container: StepRunner) { 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}") container.log("Fetching latest release from ${RadiantLyricsGithubService.REPO_OWNER}/${RadiantLyricsGithubService.REPO_NAME}")
val release = github.getLatestRelease(force = true).getOrThrow() val release = github.getLatestRelease(force = true).getOrThrow()
@@ -33,14 +71,19 @@ class FetchInfoStep : Step(), KoinComponent {
?.browserDownloadUrl ?.browserDownloadUrl
?: throw IllegalStateException("No ${RadiantLyricsGithubService.DATA_JSON_ASSET_NAME} asset found in latest release ${release.tagName}") ?: 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 } .find { it.name == RadiantLyricsGithubService.PATCHES_ASSET_NAME }
?.browserDownloadUrl ?.browserDownloadUrl
?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}") ?: throw IllegalStateException("No ${RadiantLyricsGithubService.PATCHES_ASSET_NAME} asset found in latest release ${release.tagName}")
container.log("Fetching build info from $dataJsonUrl") 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("Fetched build info: $data")
container.log("Patches asset URL: $patchesAssetUrl") 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 IS_SPLIT_REQUIRED = "isSplitRequired"
private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes" private const val REQUIRED_SPLIT_TYPES = "requiredSplitTypes"
private const val SPLIT_TYPES = "splitTypes" 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( fun patchManifest(
manifestBytes: ByteArray, manifestBytes: ByteArray,
packageName: String, packageName: String,
appName: String, appName: String,
debuggable: Boolean, debuggable: Boolean,
enableWazeIntegration: Boolean,
): ByteArray { ): ByteArray {
// Extract original package name to rewrite every reference to it // Extract original package name to rewrite every reference to it
// (permissions, provider authorities) to the new packageName. // (permissions, provider authorities) to the new packageName.
@@ -84,6 +88,49 @@ object ManifestPatcher {
} }
return when (name) { 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) { "uses-permission" -> object : NodeVisitor(nv) {
override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) { override fun attr(ns: String?, name: String?, resourceId: Int, type: Int, value: Any?) {
if (name != "maxSdkVersion") { if (name != "maxSdkVersion") {
@@ -132,6 +179,7 @@ object ManifestPatcher {
private var addUseEmbeddedDex = true private var addUseEmbeddedDex = true
private var addExtractNativeLibs = true private var addExtractNativeLibs = true
private var addMetadata = true private var addMetadata = true
private var hasWazeInitReceiver = false
override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) { override fun attr(ns: String?, name: String, resourceId: Int, type: Int, value: Any?) {
if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false if (name == REQUEST_LEGACY_EXTERNAL_STORAGE) addLegacyStorage = false
@@ -161,6 +209,22 @@ object ManifestPatcher {
return when (name) { return when (name) {
"activity" -> ReplaceAttrsVisitor(visitor, mapOf("label" to appName)) "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) { "provider" -> object : NodeVisitor(visitor) {
override fun attr( override fun attr(
ns: String?, ns: String?,
@@ -219,6 +283,39 @@ object ManifestPatcher {
0 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() super.end()
} }
} }
@@ -1,13 +1,12 @@
package com.meowarex.rlmobile.ui.components package com.meowarex.rlmobile.ui.components
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.RadiantIconButton
import com.meowarex.rlmobile.util.back import com.meowarex.rlmobile.util.back
/** /**
@@ -18,14 +17,10 @@ fun BackButton() {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val activity = LocalActivity.current val activity = LocalActivity.current
IconButton( RadiantIconButton(
onClick = { icon = painterResource(R.drawable.ic_back),
navigator?.back(activity) contentDescription = stringResource(R.string.navigation_back),
}, subtle = true,
) { onClick = { navigator?.back(activity) },
Icon( )
painter = painterResource(R.drawable.ic_back),
contentDescription = stringResource(R.string.navigation_back),
)
}
} }
@@ -1,13 +1,11 @@
package com.meowarex.rlmobile.ui.components package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextAlign import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
import androidx.compose.ui.unit.dp import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonSize
import com.meowarex.rlmobile.ui.components.radiant.RadiantButtonStyle
@Composable @Composable
fun MainActionButton( fun MainActionButton(
@@ -15,35 +13,33 @@ fun MainActionButton(
icon: Painter, icon: Painter,
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors( style: RadiantButtonStyle = RadiantButtonStyle.Accent,
containerColor = MaterialTheme.colorScheme.primary,
),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) = RadiantButton(
FilledTonalIconButton( text = text,
shape = MaterialTheme.shapes.medium, icon = icon,
colors = colors, onClick = onClick,
onClick = onClick, enabled = enabled,
enabled = enabled, style = style,
modifier = modifier size = RadiantButtonSize.Large,
.fillMaxWidth() fillWidth = true,
.height(46.dp), modifier = modifier,
) { )
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Text( @Composable
text = text, fun DangerActionButton(
style = MaterialTheme.typography.labelLarge, text: String,
textAlign = TextAlign.Center, 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 package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.RadiantButton
@Composable @Composable
fun ProjectHeader(modifier: Modifier = Modifier) { fun ProjectHeader(modifier: Modifier = Modifier) {
@@ -22,30 +26,37 @@ fun ProjectHeader(modifier: Modifier = Modifier) {
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = modifier, 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(
text = stringResource(R.string.rlmobile), text = stringResource(R.string.rlmobile),
style = MaterialTheme.typography.titleMedium.copy(fontSize = 26.sp) style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
) )
Text( Text(
text = stringResource(R.string.app_description), text = stringResource(R.string.app_description),
style = MaterialTheme.typography.titleSmall.copy( style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = .6f) color = MaterialTheme.colorScheme.onSurfaceVariant,
),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = Modifier.padding(top = 6.dp),
) { ) {
TextButton(onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") }) { RadiantButton(
Icon( text = stringResource(R.string.github),
painter = painterResource(R.drawable.ic_account_github_white_24dp), icon = painterResource(R.drawable.ic_account_github_white_24dp),
contentDescription = null, onClick = { uriHandler.openUri("https://github.com/meowarex/rl-mobile") },
modifier = Modifier.padding(end = ButtonDefaults.IconSpacing), )
)
Text(text = stringResource(R.string.github))
}
} }
} }
} }
@@ -1,15 +1,13 @@
package com.meowarex.rlmobile.ui.components package com.meowarex.rlmobile.ui.components
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.util.mirrorVertically import com.meowarex.rlmobile.ui.components.radiant.RadiantIconButton
@Composable @Composable
fun ResetToDefaultButton( fun ResetToDefaultButton(
@@ -23,15 +21,12 @@ fun ResetToDefaultButton(
exit = fadeOut() + slideOutHorizontally(), exit = fadeOut() + slideOutHorizontally(),
modifier = modifier, modifier = modifier,
) { ) {
IconButton(onClick = onClick) { RadiantIconButton(
Icon( icon = painterResource(R.drawable.ic_refresh),
painter = painterResource(R.drawable.ic_refresh), contentDescription = stringResource(R.string.action_reset_default),
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
contentDescription = stringResource(R.string.action_reset_default), subtle = true,
modifier = Modifier onClick = onClick,
.mirrorVertically() )
.size(26.dp),
)
}
} }
} }
@@ -1,25 +1,48 @@
package com.meowarex.rlmobile.ui.components package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.theme.glassSurface
/**
* Status pill
*/
@Composable @Composable
fun Tag(text: String) { fun Tag(
Surface( text: String,
color = MaterialTheme.colorScheme.primaryContainer, icon: Painter? = null,
shape = RoundedCornerShape(6.dp), 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 = text, text = text,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = tint,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
) )
} }
} }
@@ -1,35 +1,47 @@
package com.meowarex.rlmobile.ui.components package com.meowarex.rlmobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Section header
*/
@Composable @Composable
fun TextDivider( fun TextDivider(
text: String, text: String,
style: TextStyle = MaterialTheme.typography.bodyMedium, style: TextStyle = MaterialTheme.typography.labelLarge,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier modifier = modifier.fillMaxWidth(),
.fillMaxWidth(),
) { ) {
HorizontalDivider(Modifier.weight(1f)) Box(
modifier = Modifier
Text( .size(width = 14.dp, height = 3.dp)
text = text, .clip(CircleShape)
style = style, .background(MaterialTheme.colorScheme.primary),
color = MaterialTheme.colorScheme.outline,
fontWeight = FontWeight.SemiBold,
) )
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 package com.meowarex.rlmobile.ui.components.dialogs
import androidx.compose.foundation.layout.size 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.RadiantDialog
@Composable @Composable
fun InstallerAbortDialog( fun InstallerAbortDialog(
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
AlertDialog( RadiantDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { title = stringResource(R.string.installer_abort_title),
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 = {
Icon( Icon(
painter = painterResource(R.drawable.ic_warning), painter = painterResource(R.drawable.ic_warning),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(32.dp), modifier = Modifier.size(26.dp),
) )
}, },
containerColor = MaterialTheme.colorScheme.errorContainer, confirmText = stringResource(R.string.action_exit_anyways),
iconContentColor = MaterialTheme.colorScheme.onErrorContainer, onConfirm = onConfirm,
titleContentColor = MaterialTheme.colorScheme.onErrorContainer, destructive = true,
textContentColor = MaterialTheme.colorScheme.onErrorContainer 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.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.*
import com.meowarex.rlmobile.ui.theme.customColors import com.meowarex.rlmobile.ui.theme.customColors
@Composable @Composable
@@ -29,70 +28,58 @@ fun PlayProtectDialog(
var neverShow by rememberSaveable { mutableStateOf(false) } var neverShow by rememberSaveable { mutableStateOf(false) }
val rememberedNeverShow by rememberUpdatedState(neverShow) val rememberedNeverShow by rememberUpdatedState(neverShow)
AlertDialog( RadiantDialog(
onDismissRequest = { onDismiss(rememberedNeverShow) }, onDismissRequest = { onDismiss(rememberedNeverShow) },
dismissButton = { title = stringResource(R.string.play_protect_warning_title),
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 = {
Icon( Icon(
painter = painterResource(R.drawable.ic_protect_warning), painter = painterResource(R.drawable.ic_protect_warning),
tint = MaterialTheme.customColors.warning, tint = MaterialTheme.customColors.warning,
contentDescription = null, 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(16.dp)) {
Column( Text(stringResource(R.string.play_protect_warning_desc))
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally, Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = { neverShow = !rememberedNeverShow },
),
) { ) {
Text( RadiantCheckbox(
text = stringResource(R.string.play_protect_warning_desc), checked = neverShow,
textAlign = TextAlign.Center, onCheckedChange = { neverShow = it },
) )
Row( Text(stringResource(R.string.play_protect_warning_disable))
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( // Both actions are equal, they share a row
dismissOnClickOutside = false, Row(
usePlatformDefaultWidth = false, horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
), modifier = Modifier.fillMaxWidth(),
modifier = Modifier ) {
.padding(25.dp), 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() { 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( TextDivider(
text = text, 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 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.foundation.layout.*
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp 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 @Composable
fun SettingsItem( fun SettingsItem(
text: @Composable () -> Unit, text: @Composable () -> Unit,
secondaryText: @Composable (() -> Unit) = { }, secondaryText: @Composable (() -> Unit) = { },
icon: @Composable (() -> Unit) = { }, icon: @Composable (() -> Unit) = { },
position: GroupPosition = GroupPosition.Single,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit) = { }, trailing: @Composable (() -> Unit) = { },
) { ) {
val ui = radiantStyle
val iconShape = RoundedCornerShape(12.dp)
Row( Row(
modifier = modifier modifier = modifier
.heightIn(min = 64.dp)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 12.dp), // Legacy rows are flat and full bleed with no tile background or grouping. (because i'm lazy)
horizontalArrangement = Arrangement.spacedBy(8.dp), .then(
verticalAlignment = Alignment.CenterVertically 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( if (ui.groupedSettings) {
verticalArrangement = Arrangement.spacedBy(10.dp), Box(
modifier = Modifier.weight(2f, true) contentAlignment = Alignment.Center,
) { modifier = Modifier
Row( .glow(radius = 10.dp, shape = iconShape, alpha = 0.35f)
horizontalArrangement = Arrangement.spacedBy(8.dp), .size(36.dp)
verticalAlignment = Alignment.CenterVertically, .clip(iconShape)
.background(accentBrush, iconShape),
) { ) {
Box(modifier = Modifier.size(20.dp)) { CompositionLocalProvider(
icon() LocalContentColor provides MaterialTheme.colorScheme.onPrimary,
) {
Box(Modifier.size(20.dp)) { icon() }
} }
}
} else {
Box(Modifier.size(20.dp)) { icon() }
}
ProvideTextStyle(MaterialTheme.typography.titleSmall) { Column(
text() verticalArrangement = Arrangement.spacedBy(3.dp),
} modifier = Modifier.weight(1f),
) {
ProvideTextStyle(MaterialTheme.typography.titleSmall) {
text()
} }
ProvideTextStyle( ProvideTextStyle(
MaterialTheme.typography.bodyMedium.copy( MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurface.copy(0.6f) color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
) { ) {
secondaryText() secondaryText()
} }
} }
Spacer(Modifier.weight(0.05f, true))
trailing() trailing()
} }
} }
@@ -1,10 +1,9 @@
package com.meowarex.rlmobile.ui.components.settings 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.meowarex.rlmobile.ui.components.radiant.RadiantSwitch
@Composable @Composable
fun SettingsSwitch( fun SettingsSwitch(
@@ -13,11 +12,14 @@ fun SettingsSwitch(
disabled: Boolean = false, disabled: Boolean = false,
icon: @Composable () -> Unit = {}, icon: @Composable () -> Unit = {},
pref: Boolean, pref: Boolean,
position: GroupPosition = GroupPosition.Single,
onPrefChange: (Boolean) -> Unit, onPrefChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
SettingsItem( 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) }, text = { Text(text = label, softWrap = true) },
icon = icon, icon = icon,
secondaryText = { secondaryText = {
@@ -26,7 +28,7 @@ fun SettingsSwitch(
} }
} }
) { ) {
Switch( RadiantSwitch(
checked = pref, checked = pref,
enabled = !disabled, enabled = !disabled,
onCheckedChange = { onPrefChange(!pref) } onCheckedChange = { onPrefChange(!pref) }
@@ -1,11 +1,12 @@
package com.meowarex.rlmobile.ui.components.settings package com.meowarex.rlmobile.ui.components.settings
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.ui.components.radiant.RadiantTextField
@Composable @Composable
fun SettingsTextField( fun SettingsTextField(
@@ -15,15 +16,21 @@ fun SettingsTextField(
error: Boolean = false, error: Boolean = false,
onPrefChange: (String) -> Unit, onPrefChange: (String) -> Unit,
) { ) {
Box(modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)) { Column(
OutlinedTextField( verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth(), 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, value = pref,
onValueChange = onPrefChange, onValueChange = onPrefChange,
enabled = !disabled, enabled = !disabled,
label = { Text(label) },
isError = error, 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.*
import androidx.compose.foundation.layout.* 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 android.content.res.Configuration
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.* import androidx.compose.ui.tooling.preview.*
import com.meowarex.rlmobile.network.utils.SemVer import com.meowarex.rlmobile.network.utils.SemVer
import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent import com.meowarex.rlmobile.ui.screens.componentopts.PatchComponent
import com.meowarex.rlmobile.ui.screens.patchopts.* import com.meowarex.rlmobile.ui.screens.patchopts.*
import com.meowarex.rlmobile.ui.theme.ManagerTheme import com.meowarex.rlmobile.ui.theme.ManagerTheme
import kotlinx.serialization.json.Json
import kotlin.time.Clock import kotlin.time.Clock
// This preview has scrollable/interactable content that cannot be tested from an IDE preview // This preview has scrollable/interactable content that cannot be tested from an IDE preview
@@ -18,28 +21,37 @@ private fun PatchOptionsScreenPreview(
@PreviewParameter(PatchOptionsParametersProvider::class) @PreviewParameter(PatchOptionsParametersProvider::class)
parameters: PatchOptionsParameters, parameters: PatchOptionsParameters,
) { ) {
val context = LocalContext.current
val specs = remember { builtinPatchSpecs(context, Json { ignoreUnknownKeys = true }) }
ManagerTheme { ManagerTheme {
PatchOptionsScreenContent( PatchOptionsScreenContent(
isUpdate = parameters.isUpdate, isUpdate = parameters.isUpdate,
isDevMode = parameters.isDevMode, isDevMode = parameters.isDevMode,
debuggable = parameters.debuggable, debuggable = parameters.debuggable,
setDebuggable = {}, setDebuggable = {},
bypassIncompatible = false,
setBypassIncompatible = {},
appName = parameters.appName, appName = parameters.appName,
appNameIsError = parameters.appNameIsError, appNameIsError = parameters.appNameIsError,
setAppName = {}, setAppName = {},
packageName = parameters.packageName, packageName = parameters.packageName,
packageNameState = parameters.packageNameState, packageNameState = parameters.packageNameState,
setPackageName = {}, setPackageName = {},
packageNameLocked = parameters.packageName == PatchOptions.Default.packageName,
onUnlockPackageName = {},
onLockPackageName = {},
customTidalApk = parameters.customTidalApk, customTidalApk = parameters.customTidalApk,
onSelectCustomTidalApk = {}, onSelectCustomTidalApk = {},
customPatches = parameters.customPatches, customPatches = parameters.customPatches,
onSelectCustomPatches = {}, onSelectCustomPatches = {},
enabledPatchCount = KnownPatch.All.size, specs = specs,
isPatchEnabled = { true }, isPatchEnabled = { true },
onTogglePatch = { _, _ -> }, onTogglePatch = { _, _ -> },
patchLockState = { PatchLock.Free }, patchLockState = { PatchLock.Free },
variantIndex = { 0 }, variantIndex = { 0 },
onSelectVariant = { _, _ -> }, onSelectVariant = { _, _ -> },
optionState = PatchOptionState.Preview,
isConfigValid = parameters.isConfigValid, isConfigValid = parameters.isConfigValid,
onInstall = {}, onInstall = {},
) )
@@ -85,7 +85,7 @@ fun AboutScreenContent(state: State<AboutScreenState>) {
AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") { AboutScreenState.Loading -> item(key = "CONTRIBUTIONS_LOADING") {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
content = { CircularProgressIndicator() }, content = { LoadingIndicator() },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 38.dp), .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.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R 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.screens.componentopts.components.*
import com.meowarex.rlmobile.ui.util.ScreenWithResult import com.meowarex.rlmobile.ui.util.ScreenWithResult
import com.meowarex.rlmobile.ui.util.paddings.* import com.meowarex.rlmobile.ui.util.paddings.*
@@ -160,11 +162,11 @@ fun ComponentOptionsScreenContent(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 10.dp), .padding(top = 10.dp),
) { ) {
FilledTonalButton( RadiantButton(
text = stringResource(R.string.action_confirm),
onClick = onBackPressed, onClick = onBackPressed,
) { size = RadiantButtonSize.Small,
Text(stringResource(R.string.action_confirm)) )
}
} }
} }
} }
@@ -312,18 +314,16 @@ private fun ReleaseRow(
color = LocalContentColor.current.copy(alpha = 0.55f), color = LocalContentColor.current.copy(alpha = 0.55f),
) )
} }
FilledTonalButton( // Swap the button out for the indicator while this specific release imports
onClick = onImport, if (importing) {
enabled = !anyImporting, CircularWavyProgressIndicator(modifier = Modifier.size(30.dp))
) { } else {
if (importing) { RadiantButton(
CircularProgressIndicator( text = stringResource(R.string.action_install),
strokeWidth = 2.dp, onClick = onImport,
modifier = Modifier.size(16.dp), enabled = !anyImporting,
) size = RadiantButtonSize.Small,
} else { )
Text(stringResource(R.string.action_install))
}
} }
} }
} }
@@ -19,9 +19,7 @@ fun ComponentOptionsAppBar(
TopAppBar( TopAppBar(
navigationIcon = { BackButton() }, navigationIcon = { BackButton() },
title = { title = {
Column( Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(stringResource(R.string.componentopts_screen_title, componentType.name)) Text(stringResource(R.string.componentopts_screen_title, componentType.name))
Text( Text(
text = stringResource(R.string.componentopts_screen_desc), 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.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.meowarex.rlmobile.R 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 com.meowarex.rlmobile.network.utils.SemVer
import kotlin.time.Instant import kotlin.time.Instant
@@ -65,15 +67,13 @@ fun PatchComponentCard(
Spacer(Modifier.weight(1f, fill = true)) 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, 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, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
) { ) {
RadioButton( RadiantRadio(
selected = selected, selected = selected,
onClick = onSelect, onClick = onSelect,
interactionSource = interaction, interactionSource = interaction,
@@ -19,7 +19,9 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.github.diamondminer88.zip.ZipReader import com.github.diamondminer88.zip.ZipReader
import com.meowarex.rlmobile.BuildConfig import com.meowarex.rlmobile.BuildConfig
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.manager.PathManager
import com.meowarex.rlmobile.manager.PreferencesManager 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.GithubCommit
import com.meowarex.rlmobile.network.models.RLBuildInfo import com.meowarex.rlmobile.network.models.RLBuildInfo
import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService import com.meowarex.rlmobile.network.services.RadiantLyricsGithubService
@@ -42,6 +44,7 @@ class HomeModel(
private val github: RadiantLyricsGithubService, private val github: RadiantLyricsGithubService,
private val json: Json, private val json: Json,
private val prefs: PreferencesManager, private val prefs: PreferencesManager,
private val paths: PathManager,
) : ScreenModel { ) : ScreenModel {
var state by mutableStateOf<HomeState>(HomeState.Loading) var state by mutableStateOf<HomeState>(HomeState.Loading)
@@ -170,16 +173,23 @@ class HomeModel(
refreshingLock.withLock { refreshingLock.withLock {
val pkg = fetchInstalled() val pkg = fetchInstalled()
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() } // Skip the remote fetch entirely when offline
remote.await() val online = application.isOnline()
if (online) {
val remote = async(Dispatchers.IO) { if (remoteDataJson == null) fetchRemoteData() }
remote.await()
}
val install = pkg?.toInstallData() val install = pkg?.toInstallData()
val latest = remoteDataJson?.tidalVersionCode val latest = remoteDataJson?.tidalVersionCode
val offlineRepatchReady = hasOfflineRepatchAssets()
mainThread { mainThread {
state = HomeState.Loaded( state = HomeState.Loaded(
install = install, install = install,
latestTidalVersionCode = latest, latestTidalVersionCode = latest,
offline = !online,
offlineRepatchReady = offlineRepatchReady,
) )
maybeCheckManagerUpdate(pkg) maybeCheckManagerUpdate(pkg)
} }
@@ -201,6 +211,14 @@ class HomeModel(
return createPrefilledPatchOptsScreen(current.packageName) 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) { fun openApp(packageName: String) {
val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName) val launchIntent = application.packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) { if (launchIntent != null) {
@@ -218,11 +236,12 @@ class HomeModel(
application.startActivity(launchIntent) application.startActivity(launchIntent)
} }
fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen { fun createPrefilledPatchOptsScreen(packageName: String): PatchOptionsScreen =
val patchOptions = loadInstallMetadata(packageName)?.options PatchOptionsScreen(prefilledOptions = prefilledPatchOptions(packageName))
fun prefilledPatchOptions(packageName: String): PatchOptions =
loadInstallMetadata(packageName)?.options
?: PatchOptions.Default.copy(packageName = packageName) ?: PatchOptions.Default.copy(packageName = packageName)
return PatchOptionsScreen(prefilledOptions = patchOptions)
}
private fun loadInstallMetadata(packageName: String): InstallMetadata? = try { private fun loadInstallMetadata(packageName: String): InstallMetadata? = try {
val applicationInfo = application.packageManager.getApplicationInfo(packageName, 0) 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() { private suspend fun fetchRemoteData() {
val release = try { val release = try {
github.getLatestRelease().fold( github.getLatestRelease().fold(
@@ -1,11 +1,12 @@
package com.meowarex.rlmobile.ui.screens.home package com.meowarex.rlmobile.ui.screens.home
import android.os.Parcelable import android.os.Parcelable
import androidx.activity.ComponentActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -14,7 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter 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.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import com.meowarex.rlmobile.R 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.components.Tag
import com.meowarex.rlmobile.ui.theme.radiantStyle
import com.meowarex.rlmobile.ui.screens.about.AboutScreen import com.meowarex.rlmobile.ui.screens.about.AboutScreen
import com.meowarex.rlmobile.ui.screens.home.components.CommitList import com.meowarex.rlmobile.ui.screens.home.components.CommitList
import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen import com.meowarex.rlmobile.ui.screens.patchopts.PatchOptionsScreen
@@ -42,6 +48,15 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koin.androidx.compose.koinViewModel 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 @Parcelize
class HomeScreen : Screen, Parcelable { class HomeScreen : Screen, Parcelable {
@IgnoredOnParcel @IgnoredOnParcel
@@ -63,35 +78,37 @@ class HomeScreen : Screen, Parcelable {
Scaffold( Scaffold(
topBar = { topBar = {
// Compact bar
TopAppBar( TopAppBar(
title = { Text(stringResource(R.string.navigation_home)) }, title = { Text(stringResource(R.string.rlmobile)) },
actions = { actions = {
IconButton(onClick = { model.refresh() }) { RadiantIconButton(
Icon( icon = painterResource(R.drawable.ic_refresh),
painterResource(R.drawable.ic_refresh), contentDescription = stringResource(R.string.navigation_refresh),
contentDescription = stringResource(R.string.navigation_refresh), subtle = true,
) onClick = { model.refresh() },
} )
if (managerUpdateAvailable) { if (managerUpdateAvailable) {
IconButton(onClick = updater::reopenDialog) { RadiantIconButton(
Icon( icon = painterResource(R.drawable.ic_update),
painterResource(R.drawable.ic_update), contentDescription = stringResource(R.string.action_update),
contentDescription = stringResource(R.string.action_update), accent = true,
) size = 40.dp,
} onClick = updater::reopenDialog,
}
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_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), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier modifier = Modifier
.padding(pv) .padding(pv)
.padding(16.dp) .padding(horizontal = 16.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
val state = model.state when (val state = model.state) {
when (state) {
HomeState.Loading -> Box( HomeState.Loading -> Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(), 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( is HomeState.Loaded -> HomeContent(
state = state, state = state,
@@ -153,6 +172,83 @@ private fun ColumnScope.HomeContent(
?.let { "v${it.name} (build ${it.code})" } ?.let { "v${it.name} (build ${it.code})" }
val latestVersionName = state.latestTidalVersionCode?.let { "build $it" } 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) { val fallbackPainter = if (install?.icon == null) {
// R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode. // R.mipmap.ic_launcher is an adaptive-icon XML on API 26+, which painterResource cannot decode.
val context = LocalContext.current val context = LocalContext.current
@@ -163,96 +259,108 @@ private fun ColumnScope.HomeContent(
} else null } else null
val iconPainter = install?.icon ?: fallbackPainter val iconPainter = install?.icon ?: fallbackPainter
if (iconPainter != null) {
Image( val hero = radiantStyle.heroCard
painter = iconPainter,
contentDescription = null, // 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 modifier = Modifier
.size(60.dp) .fillMaxWidth()
.clip(CircleShape), .padding(horizontal = 18.dp, vertical = 18.dp),
)
}
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)),
) { ) {
SegmentedButton( if (iconPainter != null) {
icon = painterResource(R.drawable.ic_launch), // Clipped to a 9-sided cookie rather than a circle — the app icon is the one place
text = stringResource(R.string.action_launch), // a non-rectilinear silhouette reads as deliberate instead of noisy. (thx claude again)
onClick = onLaunch, Image(
) painter = iconPainter,
SegmentedButton( contentDescription = null,
icon = painterResource(R.drawable.ic_info), modifier = Modifier
text = stringResource(R.string.action_open_info), .size(60.dp)
onClick = onInfo, .clip(MaterialShapes.Cookie9Sided.toShape()),
) )
} }
}
ElevatedCard(modifier = Modifier.fillMaxSize()) { Text(
CommitList(commits = commits.collectAsLazyPagingItems()) 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( data class Loaded(
val install: InstallData?, val install: InstallData?,
val latestTidalVersionCode: Int?, val latestTidalVersionCode: Int?,
val offline: Boolean = false,
val offlineRepatchReady: Boolean = false,
) : HomeState ) : HomeState
} }
@@ -1,6 +1,8 @@
package com.meowarex.rlmobile.ui.screens.home.components package com.meowarex.rlmobile.ui.screens.home.components
import androidx.compose.foundation.clickable 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -59,7 +61,11 @@ fun CommitList(commits: LazyPagingItems<GithubCommit>) {
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
) { ) {
Text("Failed to load commits", style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center) 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.ui.components.radiant.RadiantIconButton
import com.meowarex.rlmobile.ui.components.BackButton import com.meowarex.rlmobile.ui.components.BackButton
@Composable @Composable
@@ -16,18 +17,18 @@ fun LogAppBar(
title = { Text(stringResource(R.string.log_title)) }, title = { Text(stringResource(R.string.log_title)) },
navigationIcon = { BackButton() }, navigationIcon = { BackButton() },
actions = { actions = {
IconButton(onClick = onExportLog) { RadiantIconButton(
Icon( icon = painterResource(R.drawable.ic_save),
painter = painterResource(R.drawable.ic_save), contentDescription = stringResource(R.string.log_action_export),
contentDescription = stringResource(R.string.log_action_export), subtle = true,
) onClick = onExportLog,
} )
IconButton(onClick = onShareLog) { RadiantIconButton(
Icon( icon = painterResource(R.drawable.ic_share),
painter = painterResource(R.drawable.ic_share), contentDescription = stringResource(R.string.log_action_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.BuildConfig
import com.meowarex.rlmobile.R import com.meowarex.rlmobile.R
import com.meowarex.rlmobile.patcher.steps.StepGroup 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.MainActionButton
import com.meowarex.rlmobile.ui.components.Wakelock import com.meowarex.rlmobile.ui.components.Wakelock
import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog import com.meowarex.rlmobile.ui.components.dialogs.InstallerAbortDialog
@@ -130,6 +131,14 @@ class PatchingScreen(
modifier = Modifier modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom)), .padding(paddingValues.exclude(PaddingValuesSides.Bottom)),
) { ) {
OverallProgressBar(
steps = model.steps,
state = state,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
LazyColumn( LazyColumn(
state = listState, state = listState,
verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp), verticalArrangement = Arrangement.spacedByLastAtBottom(0.dp),
@@ -178,9 +187,9 @@ class PatchingScreen(
TextBanner( TextBanner(
text = stringResource(R.string.installer_banner_success), text = stringResource(R.string.installer_banner_success),
icon = painterResource(R.drawable.ic_check_circle), icon = painterResource(R.drawable.ic_check_circle),
iconColor = Color(0xFF59B463), iconColor = MaterialTheme.customColors.success,
outlineColor = MaterialTheme.colorScheme.surfaceVariant, outlineColor = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = MaterialTheme.customColors.successContainer,
modifier = Modifier modifier = Modifier
.padding(bottom = VERTICAL_PADDING) .padding(bottom = VERTICAL_PADDING)
.fillMaxWidth(), .fillMaxWidth(),
@@ -249,13 +258,10 @@ class PatchingScreen(
} }
} }
MainActionButton( DangerActionButton(
text = stringResource(R.string.settings_clear_cache), text = stringResource(R.string.settings_clear_cache),
icon = painterResource(R.drawable.ic_delete_forever), icon = painterResource(R.drawable.ic_delete_forever),
enabled = !cacheCleared, enabled = !cacheCleared,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.error,
),
onClick = { onClick = {
cacheCleared = true cacheCleared = true
model.clearCache() model.clearCache()
@@ -13,4 +13,7 @@ val PatchingScreenState.isProgressChange: Boolean
inline get() = this != CloseScreen inline get() = this != CloseScreen
val PatchingScreenState.isFinished: Boolean 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(),
)
}
}
}

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