Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c4be44f2 | ||
|
|
3d83c3116c | ||
|
|
2b00898355 | ||
|
|
975a9a101f | ||
|
|
793659c63b | ||
|
|
5bdc1d7f59 | ||
|
|
fc66453167 | ||
|
|
84a9d01d6a | ||
|
|
a4b948bba3 | ||
|
|
8c720992de | ||
|
|
d1fc97cee8 | ||
|
|
9df49f1bff | ||
|
|
652062769e | ||
|
|
f1eb833655 | ||
|
|
a27753d8ae | ||
|
|
da8a261202 | ||
|
|
532ebfe0a7 | ||
|
|
bf7f371022 | ||
|
|
15c9a3fd5d | ||
|
|
bb0ab20a5b | ||
|
|
9a507684ee | ||
|
|
1fbf82b4fb | ||
|
|
53696c26f4 | ||
|
|
381b190535 | ||
|
|
50199b052f | ||
|
|
2135d66124 | ||
|
|
6948d86efc | ||
|
|
481e5840f0 | ||
|
|
216bb71fef | ||
|
|
181d19424f | ||
|
|
a4d2513138 | ||
|
|
9c0f14e797 | ||
|
|
800b40aaae | ||
|
|
9577dadd38 | ||
|
|
d4a084f896 | ||
|
|
28df958197 | ||
|
|
9b0f336f91 | ||
|
|
c52f60a29b | ||
|
|
a327786d9e | ||
|
|
2b0c6cae36 | ||
|
|
05a383c823 | ||
|
|
13cd3656fd | ||
|
|
07a8d3041e | ||
|
|
5ece2ec408 | ||
|
|
7f9b911f0b | ||
|
|
b1b6a4ae6f | ||
|
|
eeee0c2e72 | ||
|
|
07d4a4f833 | ||
|
|
4adcff36e3 | ||
|
|
26077a8c87 | ||
|
|
28982aa655 | ||
|
|
89748cd261 | ||
|
|
97797127bd | ||
|
|
23d8ff0370 | ||
|
|
d986440f9f | ||
|
|
fc3b1bcde1 | ||
|
|
0289f96019 | ||
|
|
ec9ecac15c | ||
|
|
5efccc9e78 | ||
|
|
ae53cc1f72 | ||
|
|
5b170ebae6 | ||
|
|
87ca0eaa82 | ||
|
|
5496dc7eb5 | ||
|
|
183164fd52 | ||
|
|
4987577001 | ||
|
|
c7a879fa62 | ||
|
|
d6e74cb207 | ||
|
|
1ccdaee879 | ||
|
|
956bdda6c0 | ||
|
|
1b1f68c6cf | ||
|
|
b6f9c10b21 | ||
|
|
fb3f1f55c7 | ||
|
|
f8e5d7370a | ||
|
|
7623ec30c9 | ||
|
|
7be8e33cc6 | ||
|
|
bc252ea9a3 | ||
|
|
4aba9c815f | ||
|
|
05c71c7ef4 | ||
|
|
57ebadbc51 | ||
|
|
bdf3808526 | ||
|
|
e8d1f62ff1 | ||
|
|
b64faf31ae | ||
|
|
ed86916ed7 | ||
|
|
5935afb6e1 | ||
|
|
1ac1542384 | ||
|
|
8eea50e745 | ||
|
|
795bc1f96a | ||
|
|
0ab60d8b20 | ||
|
|
627abb3927 | ||
|
|
f32a4c4a23 | ||
|
|
4753d91315 | ||
|
|
f1dc4429d8 | ||
|
|
e6135584d4 | ||
|
|
ea82a5d373 | ||
|
|
36aa0c52a2 | ||
|
|
e3008e0c9c | ||
|
|
c50327bfe9 | ||
|
|
a97449605a | ||
|
|
e53a7db9ed | ||
|
|
c16f1f130d | ||
|
|
6eec014493 | ||
|
|
a21b938be0 | ||
|
|
3744c5b422 | ||
|
|
4692987baf | ||
|
|
869bf3f3b4 | ||
|
|
ce67a4f2a7 | ||
|
|
6c1072171d | ||
|
|
6559d4dcd2 | ||
|
|
a1aef48b0c | ||
|
|
0779a496eb | ||
|
|
6227a078e4 | ||
|
|
dac42ea042 | ||
|
|
0df57f7724 | ||
|
|
8d6dcdb227 | ||
|
|
46e9b7e7f5 | ||
|
|
d485718245 | ||
|
|
a4de81aaf6 | ||
|
|
a8e0c53be0 | ||
|
|
a0728fa951 | ||
|
|
5c7b342195 | ||
|
|
67dcc16923 | ||
|
|
214ec4ffdd | ||
|
|
a821cdf85e | ||
|
|
1953397adf | ||
|
|
c20c157c2b | ||
|
|
60c1bf8569 | ||
|
|
08610ee398 | ||
|
|
1e6e9ada62 | ||
|
|
528adc6255 | ||
|
|
c4246e853c | ||
|
|
ff398ccc9a | ||
|
|
3ce4617deb | ||
|
|
228d5e0b23 | ||
|
|
d65938ab69 | ||
|
|
e0ea732855 | ||
|
|
58928f70e9 | ||
|
|
0d9267c13d | ||
|
|
9e41e8154a | ||
|
|
c5f164ccc8 | ||
|
|
c9052cba26 | ||
|
|
00ca6861f4 | ||
|
|
458e1dde0d | ||
|
|
a3441e02a8 | ||
|
|
79f9715001 | ||
|
|
ca35f68e88 | ||
|
|
79d1f9098a | ||
|
|
f551daf3a4 | ||
|
|
01594b24b5 | ||
|
|
a92f577aec | ||
|
|
86370bd9d3 | ||
|
|
296769af49 | ||
|
|
1401aa90cc | ||
|
|
6a752b68d9 | ||
|
|
fe6785f7f1 | ||
|
|
06046340c6 | ||
|
|
403c9df173 | ||
|
|
67b493e6bb | ||
|
|
cd924b1b0d | ||
|
|
5d2a3dedb7 | ||
|
|
7fe49f2fe4 | ||
|
|
327efafbe5 | ||
|
|
de4d8dac39 | ||
|
|
d8d82cd098 | ||
|
|
222b4885ba | ||
|
|
ef4f4c78cb | ||
|
|
ec05a12e6c | ||
|
|
2c5f54368c | ||
|
|
18f280cfd7 | ||
|
|
d8a7af7614 | ||
|
|
c221beaa30 | ||
|
|
c3cee0c911 | ||
|
|
835dacd491 | ||
|
|
77b0a8e02a | ||
|
|
e49146504a | ||
|
|
113f47e3c1 | ||
|
|
2912d211a5 | ||
|
|
705fcca5ee | ||
|
|
204fde4857 | ||
|
|
caf57bfb5e | ||
|
|
d765767299 | ||
|
|
7028fc5189 | ||
|
|
fdc5bf4f17 | ||
|
|
c84b269601 | ||
|
|
d4fde5d5b9 | ||
|
|
c311f7714a | ||
|
|
4e3deb33fc | ||
|
|
9fa733f98d | ||
|
|
1ba7e411ca | ||
|
|
d3b7ddda40 | ||
|
|
4961a36c63 | ||
|
|
af7b231561 | ||
|
|
e4c6c5fde8 | ||
|
|
b4f95c2fd4 | ||
|
|
15b35f85b2 | ||
|
|
3f11e09a00 | ||
|
|
7889fb2ac8 | ||
|
|
488d107d99 | ||
|
|
1bf6335ca2 | ||
|
|
dd27ed9450 | ||
|
|
f6ac584991 | ||
|
|
8a91ab87a2 | ||
|
|
eea3d9ea53 | ||
|
|
e9de1a5744 | ||
|
|
701e17aa21 | ||
|
|
91db54a141 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -45,7 +45,7 @@ body:
|
||||
attributes:
|
||||
label: "SnapEnhance Version"
|
||||
description: "On which SnapEnhance version is this happening?"
|
||||
placeholder: "ex. 1.2.5"
|
||||
placeholder: "ex. 1.3.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
|
||||
207
.github/workflows/debug.yml
vendored
207
.github/workflows/debug.yml
vendored
@@ -28,22 +28,68 @@ jobs:
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv8Debug
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: echo "BUILD_TOOL_VERSION=$(ls "$ANDROID_HOME/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
id: sign_app
|
||||
uses: SnapEnhance/sign-android-release@master
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/armv8/debug/
|
||||
signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv8/debug"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv8Debug-aligned.apk"
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv8Debug-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv8Debug-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
@@ -52,7 +98,7 @@ jobs:
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
|
||||
mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
|
||||
mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -80,22 +126,68 @@ jobs:
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Debug
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: echo "BUILD_TOOL_VERSION=$(ls \"$ANDROID_HOME/build-tools/\" | tail -n 1)" >> $GITHUB_ENV
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
id: sign_app
|
||||
uses: SnapEnhance/sign-android-release@master
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/armv7/debug/
|
||||
signingKeyBase64: ${{ secrets.JAVA_KEYSTORE_DATA }}
|
||||
alias: ${{ secrets.KEY_ALIAS }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv7/debug"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
|
||||
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv7Debug-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
@@ -104,85 +196,10 @@ jobs:
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
|
||||
mv ${{steps.sign_app.outputs.signedReleaseFile}} app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-debug
|
||||
path: app/build/outputs/apk/armv7/debug/*.apk
|
||||
job_manager:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
manager_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache manager:assembleDebug
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: manager
|
||||
path: manager/build/outputs/apk/debug/*.apk
|
||||
job_core:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
core_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleCoreDebug
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
path: app/build/outputs/apk/core/debug/*.apk
|
||||
make_prerelease:
|
||||
needs: [job_armv7, job_armv8, job_manager, job_core]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./all-apks
|
||||
- name: Display all files for debug
|
||||
run: find ./all-apks
|
||||
- name: Set up GitHub CLI
|
||||
run: |
|
||||
sudo apt-get install -y gh
|
||||
- name: Create Debug Prerelease with all APKs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="debug-${{ github.run_id }}-${{ github.sha }}"
|
||||
TITLE="Debug CI $VERSION"
|
||||
NOTE="Auto-generated debug prerelease including all APKs from jobs."
|
||||
APKLIST=$(find ./all-apks -type f -name '*.apk' | tr '\n' ' ')
|
||||
echo "APKS: $APKLIST"
|
||||
gh release create "$VERSION" $APKLIST --repo ${{ github.repository }} --title "$TITLE" --notes "$NOTE" --prerelease
|
||||
|
||||
236
.github/workflows/release.yml
vendored
Normal file
236
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
name: PurrfectSnap Release CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ci_upload:
|
||||
description: 'Upload to CI channel'
|
||||
required: false
|
||||
type: boolean
|
||||
jobs:
|
||||
job_armv8:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv8Release
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv8/release"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv8Release-aligned.apk"
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv8Release-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv8Release-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv8/release/ -type f ! -name '*-signed*' -delete
|
||||
mv app/build/outputs/apk/armv8/release/purrfectsnap-armv8Release-signed.apk app/build/outputs/apk/armv8/release/purrfectsnap_${{ env.version }}-armv8Release.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv8-release
|
||||
path: app/build/outputs/apk/armv8/release/*.apk
|
||||
job_armv7:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Setup NPM Dependencies
|
||||
run: npm install typescript -g
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.android"
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
|
||||
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "${HOME}/.gradle"
|
||||
{
|
||||
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
|
||||
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
|
||||
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
|
||||
} >> "${HOME}/.gradle/gradle.properties"
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Release
|
||||
- name: Determine the latest Build Tools version installed
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
|
||||
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
|
||||
- name: Sign APK
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR="app/build/outputs/apk/armv7/release"
|
||||
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
|
||||
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
|
||||
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
|
||||
if [ ! -s "${KEYSTORE_PATH}" ]; then
|
||||
echo "Decoded keystore is empty or invalid." >&2
|
||||
exit 1
|
||||
fi
|
||||
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
|
||||
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
|
||||
--ks "${KEYSTORE_PATH}" \
|
||||
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
|
||||
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
|
||||
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
|
||||
--ks-type JKS \
|
||||
--out "${SIGNING_DIR}/purrfectsnap-armv7Release-signed.apk" \
|
||||
"${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv7/release/ -type f ! -name '*-signed*' -delete
|
||||
mv app/build/outputs/apk/armv7/release/purrfectsnap-armv7Release-signed.apk app/build/outputs/apk/armv7/release/purrfectsnap_${{ env.version }}-armv7Release.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-release
|
||||
path: app/build/outputs/apk/armv7/release/*.apk
|
||||
make_release:
|
||||
needs: [job_armv7, job_armv8]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./all-apks
|
||||
- name: Display all files for debug
|
||||
run: find ./all-apks
|
||||
- name: Set up GitHub CLI
|
||||
run: |
|
||||
sudo apt-get install -y gh
|
||||
- name: Create Release with all APKs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(awk 'NR==1 && $1=="##" {print $2; exit}' changelogs-stable.txt)
|
||||
NOTE=$(awk 'NR==1 {next} /^## / {exit} {sub(/^[[:space:]]+/, "", $0); print}' changelogs-stable.txt)
|
||||
if [ -z "${VERSION}" ] || [ -z "${NOTE}" ]; then
|
||||
echo "Failed to parse changelogs-stable.txt for release title/body." >&2
|
||||
exit 1
|
||||
fi
|
||||
TITLE="${VERSION}"
|
||||
APKLIST=$(find ./all-apks -type f -name '*.apk' | tr '\n' ' ')
|
||||
echo "APKS: $APKLIST"
|
||||
gh release create "$VERSION" $APKLIST --repo ${{ github.repository }} --title "$TITLE" --notes "$NOTE"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ cloudflare/**/allowed-codes.local.*
|
||||
cloudflare/**/allowed_codes.local.*
|
||||
security/allowed-codes.local.*
|
||||
security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
|
||||
391
README.md
391
README.md
@@ -1,79 +1,382 @@
|
||||
<br>
|
||||
<div align="center">
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap Logo" width="150"/>
|
||||
<h1>PurrfectSnap 🐾✨</h1>
|
||||
<h3>A fork of Snapenhance made to <em>meow</em> your Snapchat experience. 😉</h3>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/particle-box/PurrfectSnap/releases/latest"><img src="https://img.shields.io/github/v/release/particle-box/PurrfectSnap?style=for-the-badge&color=cba6f7&labelColor=302d41&label=Release" alt="Latest Release"></a>
|
||||
<a href="https://github.com/particle-box/PurrfectSnap/releases"><img src="https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=302d41&label=Downloads" alt="Downloads"></a>
|
||||
<a href="https://github.com/particle-box/PurrfectSnap/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=302d41" alt="License"></a>
|
||||
</p>
|
||||
<div align="center">
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/banner.jpg" alt="PurrfectSnap" width="100%"/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
# PurrfectSnap
|
||||
|
||||
### An Xposed module meant to redefine your Snapchat experience! Works on both non-rooted and rooted devices!
|
||||
|
||||
<br>
|
||||
|
||||
[](https://github.com/particle-box/PurrfectSnap/releases/latest)
|
||||
[](https://github.com/particle-box/PurrfectSnap/releases)
|
||||
[](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
|
||||
|
||||
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
## 🐈 What is PurrfectSnap?
|
||||
## Overview
|
||||
|
||||
**PurrfectSnap** enhances your Snapchat experience by building upon the incredible foundation of the original Snapenhance project. Our goal is to actively develop and integrate powerful, modern features that give you unparalleled control and a truly unique aesthetic.
|
||||
PurrfectSnap is built on the foundation of SnapEnhance, pushing boundaries with innovative features and a refined user experience. This isn't just another fork; it's a complete reimagining of what's possible.
|
||||
|
||||
We're committed to active development, bringing you powerful tools that actually matter. Every feature is designed with real users in mind, not just for the sake of adding to a feature list.
|
||||
|
||||
<br>
|
||||
|
||||
## 🙏 Acknowledgements
|
||||
## Philosophy
|
||||
|
||||
This project would not be possible without the monumental effort and dedication of the **original Snapenhance team**. We are immensely grateful for their work. PurrfectSnap is our way of contributing to their vision by exploring new features and ideas that we believe the community will love.
|
||||
This project exists because we believe in continuous innovation. We're grateful to the original SnapEnhance team for their groundbreaking work, and we're building on that legacy by exploring new possibilities and listening to what the community actually wants.
|
||||
|
||||
No aggressive donation requests. No minimal changes disguised as “major updates.” Just genuine development driven by a passion for creating something exceptional.
|
||||
|
||||
<br>
|
||||
|
||||
## 🚀 What's New in the Litterbox?
|
||||
## Features
|
||||
|
||||
PurrfectSnap isn't just a maintenance fork. It's a complete evolution. Here’s what sets us apart from the upstream project:
|
||||
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
|
||||
|
||||
* ✅ **Revamped Theming Engine**: Theming is back, and it's better than ever! Includes a gorgeous, battery-saving **AMOLED theme** for the Snapenhance menu.
|
||||
* ✅ **Dynamic Bottom Bar**: Total control over your navigation. Add or remove tabs to create a layout that works for you, and even set your own default tab.
|
||||
* ✅ **Effortless Configuration**: A brand-new interface for importing and exporting your Snapenhance settings.
|
||||
* ✅ **Advanced Friend Tracker**: Seamlessly import and export your tracked friends list.
|
||||
* ✅ **Built-in Catalogs**: Discover new customizations with integrated catalogs for both **Friend Tracker** and **Scripts**.
|
||||
* ✅ **Refined Quick Actions**: We’ve updated the quick actions menu for a smoother, more intuitive workflow.
|
||||
* ✅ **Haptic Feedback**: Feel the interface with optional haptic feedback for a more tactile experience.
|
||||
* ✅ **Intelligent Auto-Updater**: Get notified about new updates and install them **directly from within the app**. No more hunting for nightly links.
|
||||
* ✅ **Fresh Coat of Paint**: Restored icons (like the beloved gear ⚙️) and a polished UI.
|
||||
* ✅ **Modern Internals**: All dependencies have been updated to their latest versions for maximum performance and stability.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### Media Downloader
|
||||
Advanced media management with extensive customization options for downloading and organizing content.
|
||||
|
||||
**Core Capabilities**
|
||||
- Custom save locations and path formatting
|
||||
- Automatic downloads from selected sources
|
||||
- Profile picture downloads
|
||||
- Voice note capture with format control
|
||||
- FFmpeg integration for advanced processing
|
||||
|
||||
**Smart Features**
|
||||
- Duplicate prevention with override option
|
||||
- Overlay merging for combined content
|
||||
- Custom logging for tracking downloads
|
||||
- Context menu integration
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### User Interface
|
||||
Complete control over your interface with deep customization options.
|
||||
|
||||
**Visual Customization**
|
||||
- Custom themes including AMOLED mode
|
||||
- Configurable icon styles
|
||||
- Message preview customization
|
||||
- Bootstrap override for default tabs
|
||||
|
||||
**Interface Control**
|
||||
- Hide unwanted UI components
|
||||
- Enhanced friend map nametags
|
||||
- Snap preview options
|
||||
- Streak expiration info display
|
||||
- Vertical story viewer
|
||||
- Message indicators and stealth mode display
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### Messaging
|
||||
Privacy-focused messaging features with advanced control over your conversations.
|
||||
|
||||
**Privacy Tools**
|
||||
- Screenshot bypass
|
||||
- Anonymous story viewing
|
||||
- Hide typing notifications
|
||||
- Hide Bitmoji presence
|
||||
- Prevent story rewatch indicators
|
||||
|
||||
**Enhanced Features**
|
||||
- Unlimited snap view time
|
||||
- Auto mark as read
|
||||
- Conversation pinning (unlimited)
|
||||
- Message logger with whitelist/blacklist
|
||||
- Better notifications with blacklist support
|
||||
- Double tap actions and reactions
|
||||
- Message retention policy bypass
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### Global Settings
|
||||
System-wide enhancements that improve your overall experience.
|
||||
|
||||
**Performance**
|
||||
- Better location handling
|
||||
- Media upload quality control
|
||||
- Custom video playback rates
|
||||
- Default volume controls
|
||||
|
||||
**Optimization**
|
||||
- Ad blocking
|
||||
- Metrics disabling
|
||||
- Story section control
|
||||
- Video length restriction bypass
|
||||
- Snap splitting disable
|
||||
- Telecom framework control
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### Camera
|
||||
Professional-grade camera controls for content creation.
|
||||
|
||||
**Recording Options**
|
||||
- Custom frame rates (front/back)
|
||||
- HEVC recording support
|
||||
- Custom resolution override
|
||||
- Force camera source encoding
|
||||
|
||||
**Creative Control**
|
||||
- Immersive preview mode
|
||||
- Black photo option
|
||||
- Startup default camera selection
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### Rules Engine
|
||||
Automation system for complex workflows.
|
||||
|
||||
- Stealth mode rules
|
||||
- Auto download conditions
|
||||
- Auto save parameters
|
||||
- Auto open snap rules
|
||||
- Unsaveable message settings
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### Experimental
|
||||
Cutting-edge features for power users.
|
||||
|
||||
**Advanced Tools**
|
||||
- Native hooks for deep customization
|
||||
- Spoofing capabilities
|
||||
- Story logger
|
||||
- Call recorder
|
||||
- Account switcher
|
||||
- App lock
|
||||
- End-to-end encryption
|
||||
- My Eyes Only passcode bypass
|
||||
|
||||
**Developer Features**
|
||||
- Better transcript
|
||||
- Friend notes
|
||||
- COF experiments
|
||||
- Custom streaks format
|
||||
- Prevent forced logout
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### Scripting
|
||||
Extensibility through custom scripts.
|
||||
|
||||
- Developer mode
|
||||
- Module folder management
|
||||
- Auto reload capability
|
||||
- Integrated UI
|
||||
- Log control options
|
||||
- Optimization toggles
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## 🥊 Claws Out: PurrfectSnap vs. Other Forks
|
||||
**Additional Features**
|
||||
|
||||
Not all forks are created equal. Some forks offer minimal changes—like basic translation or dependency updates—while aggressively seeking donations. Here’s how PurrfectSnap stacks up.
|
||||
> **Streaks Reminder** — Configurable interval notifications with remaining time display and group notification support
|
||||
|
||||
| Feature & Philosophy | **PurrfectSnap** 👑 | Other "Feature-Rich" Forks |
|
||||
| :---------------------------------- | :----------------------------------------------------------: | :------------------------: |
|
||||
| **Active Feature Development** | ✅ Constant innovation & new user-facing features | ❌ Stagnant |
|
||||
| **UI/UX Innovation** | ✅ Customizable bottom bar, new screens, AMOLED themes | ❌ Stale, outdated UI |
|
||||
| **User-Centric Tools** | ✅ Built-in catalogs, in-app updater, advanced trackers | ❌ None |
|
||||
| **Core Development Focus** | Pushing the boundaries of what's possible | Basic dependency bumps |
|
||||
| **Community Respect** | Driven by passion, features are free for everyone | Aggressive donation appeals for minor updates |
|
||||
> **Friend Tracker** — Event recording with background operation and automatic purge management
|
||||
|
||||
<br>
|
||||
|
||||
## 🔮 Future Paws-ibilities (Roadmap)
|
||||
|
||||
We're just getting started. Here are some of the exciting features planned for future releases:
|
||||
|
||||
* 💡 **Auto-Reply**: Automatically respond to Snaps and messages with custom rules.
|
||||
* 💡 **Auto-Snap Sender**: Schedule and automate sending Snaps to friends.
|
||||
* 💡 **Recommended Version Indicator**: Get clear guidance on the best Snapchat version to use with PurrfectSnap for optimal stability.
|
||||
> ⚠️Some features are intended for educational, recovery, or accessibility purposes. Misuse may violate Snapchat’s terms of service.
|
||||
> **We are not affiliated with Snap Inc. and are not responsible for any violations by the user. Our sole purpose is for education purposes only!**
|
||||
|
||||
<br>
|
||||
|
||||
## 🛠️ Installation
|
||||
---
|
||||
|
||||
**Please** refer to the **[Telegram Channel](https://t.me/purrfectsnap_official)** .
|
||||
<br>
|
||||
|
||||
## Installation
|
||||
|
||||
The guide is no longer needed!
|
||||
Just download and install PurrfectSnap from [here](https://github.com/particle-box/PurrfectSnap/releases). The app automatically detects your device type and applies the appropriate setup.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Community
|
||||
|
||||
Questions? Ideas? Found a bug? Our community is active and responsive.
|
||||
|
||||
**[Telegram Channel](https://t.me/purrfectsnap_official)** : Announcements, discussions, and support
|
||||
**For the discussions group link, refer to the description of the channel!**
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Build from Source
|
||||
|
||||
We welcome contributions from developers who share our vision. Whether it's code, documentation, etc., contributions are always appreciated. Feel free to open a PR :)
|
||||
|
||||
### 1) Fork
|
||||
|
||||
1. Fork this repository on GitHub.
|
||||
```
|
||||
|
||||
### 2) Generate a Release Keystore (Certificate)
|
||||
|
||||
Create a signing keystore (`.jks`) and keep it secure:
|
||||
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore purrfectsnap-release.keystore \
|
||||
-storetype JKS \
|
||||
-alias purrfectsnap \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000
|
||||
```
|
||||
|
||||
Optional local placement for Gradle signing:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.android
|
||||
cp purrfectsnap-release.keystore ~/.android/purrfectsnap-release.keystore
|
||||
```
|
||||
|
||||
### 3) Convert Keystore to Base64
|
||||
|
||||
For GitHub Actions secret `PS_BASE_64`:
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
base64 -w 0 purrfectsnap-release.keystore > keystore.base64
|
||||
```
|
||||
|
||||
PowerShell (Windows):
|
||||
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("purrfectsnap-release.keystore")) | Set-Content -NoNewline keystore.base64
|
||||
```
|
||||
|
||||
### 4) Add Required GitHub Secrets (Your Fork)
|
||||
|
||||
Go to:
|
||||
`Settings -> Secrets and variables -> Actions -> New repository secret`
|
||||
|
||||
Create these exact secrets (from `.github/workflows/release.yml`):
|
||||
|
||||
- `PS_BASE_64` = contents of `keystore.base64`
|
||||
- `PS_RELEASE_KEY_ALIAS` = `purrfectsnap` (or your chosen alias)
|
||||
- `PS_RELEASE_KEY_PASSWORD` = key password from `keytool`
|
||||
- `PS_RELEASE_STORE_PASSWORD` = keystore password from `keytool`
|
||||
|
||||
### 5) Run Release Workflow
|
||||
|
||||
1. Open `Actions` in your fork.
|
||||
2. Run `PurrfectSnap Release CI` (`workflow_dispatch`).
|
||||
3. Workflow builds and signs:
|
||||
- `armv8` release APK
|
||||
- `armv7` release APK
|
||||
4. Workflow creates a GitHub Release and uploads APK assets.
|
||||
|
||||
### Notes
|
||||
|
||||
- CI uses `JDK 21 (Temurin)`.
|
||||
- Signing requires all four secrets above.
|
||||
- Keep keystore and passwords private. Rotate immediately if exposed.
|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
PurrfectSnap is built with exceptional open source tools. We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies.
|
||||
|
||||
**Core Dependencies**
|
||||
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
|
||||
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
|
||||
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
|
||||
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
|
||||
- [O‑MVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation
|
||||
- [hiddenapibypass](https://github.com/LSPosed/HiddenApiBypass) — Hidden API access
|
||||
- [dexlib2 (smali)](https://github.com/JesusFreke/smali) — Dex parsing
|
||||
- [Bouncy Castle](https://www.bouncycastle.org/) — Cryptography
|
||||
- [apksig](https://android.googlesource.com/platform/tools/apksig/) — APK signing tools
|
||||
- [Rust Android Gradle Plugin](https://github.com/mozilla/rust-android-gradle) — Native build tooling
|
||||
- [Android Gradle Plugin](https://developer.android.com/build) — Build system
|
||||
- [Kotlin](https://kotlinlang.org/) — Language
|
||||
- [Compose Compiler](https://developer.android.com/jetpack/compose/compiler) — Compose compiler
|
||||
|
||||
**Media Processing**
|
||||
- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) — Media manipulation
|
||||
- [coil](https://github.com/coil-kt/coil) — Image loading
|
||||
- [smart-exception-java](https://github.com/arthenica/ffmpeg-kit) — ffmpeg-kit support lib
|
||||
|
||||
**Scripting Engine**
|
||||
- [rhino](https://github.com/mozilla/rhino) — JavaScript runtime
|
||||
- [rhino-android](https://github.com/F43nd1r/rhino-android) — Android integration
|
||||
|
||||
**Utilities**
|
||||
- [osmdroid](https://github.com/osmdroid/osmdroid) — Map functionality
|
||||
- [libsu](https://github.com/topjohnwu/libsu) — Root operations
|
||||
- [colorpicker-compose](https://github.com/skydoves/colorpicker-compose) — Color selection
|
||||
- [OkHttp](https://github.com/square/okhttp) — Networking
|
||||
- [Gson](https://github.com/google/gson) — JSON parsing
|
||||
- [jsoup](https://github.com/jhy/jsoup) — HTML parsing
|
||||
- [Fetch](https://github.com/tonyofrancis/Fetch) — Download manager
|
||||
- [WorkManager](https://developer.android.com/jetpack/androidx/releases/work) — Background tasks
|
||||
- [Accompanist](https://github.com/google/accompanist) — Compose utilities
|
||||
- [Guava](https://github.com/google/guava) — Core utilities
|
||||
- [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — Async
|
||||
- [AndroidX DocumentFile](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) — File access
|
||||
- [AndroidX RecyclerView](https://developer.android.com/jetpack/androidx/releases/recyclerview) — Lists
|
||||
- [AndroidX Navigation Compose](https://developer.android.com/jetpack/androidx/releases/navigation) — Navigation
|
||||
- [Jetpack Compose / Material3](https://developer.android.com/jetpack/compose) — UI toolkit
|
||||
- [AndroidX Activity KTX](https://developer.android.com/jetpack/androidx/releases/activity) — Activity helpers
|
||||
- [Compose BOM](https://developer.android.com/jetpack/compose/bom) — Compose version alignment
|
||||
- [Material Icons (Compose)](https://developer.android.com/jetpack/compose/designsystems/material#icons) — Icons
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<p>Made with ❤️ and a lot of catnip.</p>
|
||||
|
||||
<br>
|
||||
|
||||
Built with care, maintained with passion ❤️
|
||||
Open source under GPL-3.0, Apache-2.0
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
|
||||
1
announcements.txt
Normal file
1
announcements.txt
Normal file
@@ -0,0 +1 @@
|
||||
- Test
|
||||
@@ -100,6 +100,10 @@ fun computeKeystoreCertSha256(storeFile: File, storePass: String, keyAlias: Stri
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun gradleOrEnv(name: String, providers: org.gradle.api.provider.ProviderFactory): String? {
|
||||
return providers.gradleProperty(name).orNull ?: System.getenv(name)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = rootProject.ext["applicationId"].toString()
|
||||
compileSdk = 36
|
||||
@@ -110,13 +114,35 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
storePassword = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
keyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
keyPassword = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
val autoCertSha = providers.provider {
|
||||
computeKeystoreCertSha256(
|
||||
File(System.getProperty("user.home"), ".android/debug.keystore"),
|
||||
storePass = "android",
|
||||
keyAlias = "androiddebugkey"
|
||||
).orEmpty()
|
||||
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
val releaseKeyPass = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
|
||||
if (!releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
computeKeystoreCertSha256(
|
||||
releaseStore,
|
||||
storePass = releaseStorePass,
|
||||
keyAlias = releaseKeyAlias,
|
||||
keyPass = releaseKeyPass ?: releaseStorePass
|
||||
)
|
||||
} else {
|
||||
computeKeystoreCertSha256(
|
||||
File(System.getProperty("user.home"), ".android/debug.keystore"),
|
||||
storePass = "android",
|
||||
keyAlias = "androiddebugkey"
|
||||
)
|
||||
}.orEmpty()
|
||||
}
|
||||
val expectedCertSha256 = providers.gradleProperty("EXPECTED_CERT_SHA256")
|
||||
.orElse(providers.gradleProperty("psExpectedCertSha256"))
|
||||
@@ -125,7 +151,7 @@ android {
|
||||
applicationId = rootProject.ext["applicationId"].toString()
|
||||
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
|
||||
versionName = rootProject.ext["appVersionName"].toString()
|
||||
minSdk = 28
|
||||
minSdk = 30
|
||||
targetSdk = 36
|
||||
multiDexEnabled = true
|
||||
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
|
||||
@@ -135,6 +161,12 @@ android {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles += file("proguard-rules.pro")
|
||||
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
debug {
|
||||
(properties["debug_flavor"] == null).also {
|
||||
@@ -235,6 +267,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":core"))
|
||||
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":native"))
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
||||
|
||||
-dontwarn de.robv.android.xposed.**
|
||||
-dontwarn org.mozilla.javascript.**
|
||||
-dontwarn android.app.AndroidAppHelper
|
||||
-dontwarn java.lang.reflect.AnnotatedType
|
||||
|
||||
-keep class com.tonyodev.fetch2.** { *; }
|
||||
-keep class com.tonyodev.fetch2core.** { *; }
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="@string/app_name"
|
||||
tools:targetApi="34"
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:allowBackup="false"
|
||||
android:hasFragileUserData="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
@@ -58,11 +58,12 @@
|
||||
<activity android:name=".bridge.ForceStartActivity"
|
||||
android:theme="@android:style/Theme.NoDisplay"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false" />
|
||||
android:exported="true" />
|
||||
<activity android:name=".bridge.BiometricPromptActivity"
|
||||
android:theme="@style/BiometricPromptTheme"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false" />
|
||||
android:exported="true"
|
||||
android:permission="com.snapchat.android.permission.UPDATE_STICKER_INDEX" />
|
||||
<receiver android:name=".StreaksReminder" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -104,10 +104,10 @@ class RemoteFileHandleManager(
|
||||
File(userImportFolder, name.substringAfterLast("/"))
|
||||
)
|
||||
}
|
||||
FileHandleScope.COMPOSER -> {
|
||||
FileHandleScope.VALDI -> {
|
||||
AssetFileHandle(
|
||||
context,
|
||||
"composer/${name.substringAfterLast("/")}"
|
||||
"valdi/${name.substringAfterLast("/")}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import me.eternal.purrfectsnap.bridge.BridgeService
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
@@ -45,12 +51,14 @@ import me.eternal.purrfectsnap.ui.manager.data.SnapchatAppInfo
|
||||
import me.eternal.purrfectsnap.ui.overlay.RemoteOverlay
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.setup.SetupActivity
|
||||
import me.eternal.purrfectsnap.task.AnnouncementCheckWorker
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import com.tonyodev.fetch2.Fetch
|
||||
import com.tonyodev.fetch2.FetchConfiguration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class RemoteSideContext(
|
||||
@@ -123,6 +131,7 @@ class RemoteSideContext(
|
||||
log.init()
|
||||
log.verbose("Loading RemoteSideContext")
|
||||
config.load()
|
||||
ensureAutoUpdateCheckOnUpgrade()
|
||||
launch {
|
||||
mappings.apply {
|
||||
init(androidContext)
|
||||
@@ -132,6 +141,7 @@ class RemoteSideContext(
|
||||
userLocale = config.locale
|
||||
load()
|
||||
}
|
||||
scheduleAnnouncementCheck()
|
||||
database.init()
|
||||
streaksReminder.init()
|
||||
scriptManager.init()
|
||||
@@ -258,10 +268,48 @@ class RemoteSideContext(
|
||||
Constants.SNAPCHAT_PACKAGE_NAME
|
||||
)
|
||||
if (intent == null) {
|
||||
shortToast("Can't execute action: Snapchat is not installed")
|
||||
shortToast(translation["toast_snapchat_not_installed"])
|
||||
return
|
||||
}
|
||||
intent.putExtra(EnumAction.ACTION_PARAMETER, action.key)
|
||||
androidContext.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun scheduleAnnouncementCheck() {
|
||||
val workManager = WorkManager.getInstance(androidContext)
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val inputData = Data.Builder()
|
||||
.putString("announcements_url", "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt")
|
||||
.putString("channel_name", "Announcements")
|
||||
.putString("channel_description", "Notifications for PurrfectSnap announcements")
|
||||
.putString("notification_title", "New announcement available")
|
||||
.putString("notification_text", "Tap to open and read.")
|
||||
.build()
|
||||
val workRequest = PeriodicWorkRequestBuilder<AnnouncementCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"purrfectsnap_announcement_check",
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureAutoUpdateCheckOnUpgrade() {
|
||||
val currentVersion = BuildConfig.VERSION_CODE.toLong()
|
||||
val lastVersion = sharedPreferences.getLong("last_build_version_code", -1L)
|
||||
val reenabledOnce = sharedPreferences.getBoolean("auto_update_reenabled_once", false)
|
||||
if (lastVersion == currentVersion) return
|
||||
if (!reenabledOnce) {
|
||||
config.root.global.updateSettings.autoUpdateCheck.set(true)
|
||||
config.writeConfig()
|
||||
sharedPreferences.edit()
|
||||
.putBoolean("auto_update_reenabled_once", true)
|
||||
.apply()
|
||||
}
|
||||
sharedPreferences.edit().putLong("last_build_version_code", currentVersion).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package me.eternal.purrfectsnap.bridge
|
||||
|
||||
import android.content.Intent
|
||||
import android.hardware.biometrics.BiometricManager
|
||||
import android.hardware.biometrics.BiometricPrompt
|
||||
import android.hardware.fingerprint.FingerprintManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.CancellationSignal
|
||||
import android.app.KeyguardManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class BiometricPromptActivity: ComponentActivity() {
|
||||
private val deviceCredentialRequestCode = 2201
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -21,16 +27,50 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
}
|
||||
|
||||
val remoteSideContext = SharedContextHolder.remote(this)
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val negativeText = remoteSideContext.translation.getOrNull("biometric_auth.cancel")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Cancel"
|
||||
val titleText = remoteSideContext.translation.getOrNull("biometric_auth.title")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Unlock"
|
||||
val subtitleText = remoteSideContext.translation.getOrNull("biometric_auth.subtitle")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: "Confirm your screen lock"
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
val fingerprintManager = getSystemService(FingerprintManager::class.java)
|
||||
val hasFingerprintSupport = runCatching {
|
||||
fingerprintManager?.isHardwareDetected == true && fingerprintManager.hasEnrolledFingerprints()
|
||||
}.getOrDefault(false)
|
||||
if (!hasFingerprintSupport) {
|
||||
val keyguardManager = getSystemService(KeyguardManager::class.java)
|
||||
val canUseDeviceCredential = keyguardManager?.isKeyguardSecure == true
|
||||
if (canUseDeviceCredential) {
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(titleText, subtitleText)
|
||||
if (intent != null) {
|
||||
startActivityForResult(intent, deviceCredentialRequestCode)
|
||||
setContent {}
|
||||
return
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
setContent {}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
BiometricPrompt.Builder(this@BiometricPromptActivity)
|
||||
.setTitle(remoteSideContext.translation["biometric_auth.title"])
|
||||
.setSubtitle(remoteSideContext.translation["biometric_auth.subtitle"])
|
||||
.setTitle(titleText)
|
||||
.setSubtitle(subtitleText)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
setDeviceCredentialAllowed(true)
|
||||
} else {
|
||||
setNegativeButton(negativeText, mainExecutor) { _, _ -> cancel() }
|
||||
}
|
||||
}
|
||||
.build().authenticate(
|
||||
@@ -39,7 +79,7 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
Executors.newSingleThreadExecutor(),
|
||||
executor,
|
||||
object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) {
|
||||
cancel()
|
||||
@@ -54,4 +94,17 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
|
||||
setContent {}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Overrides deprecated API onActivityResult")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode != deviceCredentialRequestCode) return
|
||||
if (resultCode == RESULT_OK) {
|
||||
setResult(RESULT_OK, Intent())
|
||||
} else {
|
||||
setResult(RESULT_CANCELED, Intent())
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
import me.eternal.purrfectsnap.bridge.snapclient.MessagingBridge
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
@@ -16,6 +18,7 @@ import me.eternal.purrfectsnap.common.ui.OverlayType
|
||||
import me.eternal.purrfectsnap.common.util.toParcelable
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.download.call.CallDownloadSessionImpl
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
@@ -25,10 +28,25 @@ import kotlin.system.measureTimeMillis
|
||||
|
||||
class BridgeService : Service() {
|
||||
private lateinit var remoteSideContext: RemoteSideContext
|
||||
lateinit var syncCallback: SyncCallback
|
||||
private var syncCallback: SyncCallback? = null
|
||||
var messagingBridge: MessagingBridge? = null
|
||||
@Volatile
|
||||
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
|
||||
|
||||
private fun clearSyncCallback() {
|
||||
syncCallback = null
|
||||
}
|
||||
|
||||
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
|
||||
pendingSocialSnapshotCallback = callback
|
||||
}
|
||||
|
||||
fun clearEphemeralSocialSnapshotRequest() {
|
||||
pendingSocialSnapshotCallback = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
clearSyncCallback()
|
||||
if (::remoteSideContext.isInitialized) {
|
||||
remoteSideContext.bridgeService = null
|
||||
}
|
||||
@@ -45,36 +63,58 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
|
||||
val callback = syncCallback ?: return
|
||||
runCatching {
|
||||
if (!syncCallback.asBinder().pingBinder()) {
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return
|
||||
}
|
||||
|
||||
val database = remoteSideContext.database
|
||||
val syncedObject = when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
if (updateOnly && database.getFriendInfo(id) == null) return
|
||||
syncCallback.syncFriend(id)
|
||||
callback.syncFriend(id)
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
if (updateOnly && database.getGroupInfo(id) == null) return
|
||||
syncCallback.syncGroup(id)
|
||||
callback.syncGroup(id)
|
||||
}
|
||||
} ?: run {
|
||||
if (updateOnly) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> database.deleteFriend(id)
|
||||
SocialScope.GROUP -> database.deleteGroup(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> {
|
||||
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
|
||||
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) } ?: run {
|
||||
if (updateOnly) {
|
||||
database.deleteFriend(id)
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
}
|
||||
SocialScope.GROUP -> {
|
||||
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
|
||||
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) } ?: run {
|
||||
if (updateOnly) {
|
||||
database.deleteGroup(id)
|
||||
return
|
||||
}
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is RemoteException) {
|
||||
clearSyncCallback()
|
||||
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
|
||||
return@onFailure
|
||||
}
|
||||
remoteSideContext.log.error("Failed to sync $scope $id", it)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +159,7 @@ class BridgeService : Service() {
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Media conversion",
|
||||
title = remoteSideContext.translation["task_media_conversion_title"],
|
||||
author = null,
|
||||
hash = taskId
|
||||
)
|
||||
@@ -160,6 +200,7 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
override fun sync(callback: SyncCallback) {
|
||||
clearSyncCallback()
|
||||
syncCallback = callback
|
||||
measureTimeMillis {
|
||||
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
|
||||
@@ -183,10 +224,15 @@ class BridgeService : Service() {
|
||||
friends: List<String>
|
||||
) {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
remoteSideContext.database.receiveMessagingDataCallback(
|
||||
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
|
||||
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
)
|
||||
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
|
||||
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(parsedFriends, parsedGroups)
|
||||
return
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
|
||||
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
|
||||
}
|
||||
|
||||
override fun getScopeNotes(id: String): String? {
|
||||
@@ -248,5 +294,16 @@ class BridgeService : Service() {
|
||||
override fun getDebugProp(key: String, defaultValue: String?): String? {
|
||||
return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue
|
||||
}
|
||||
|
||||
override fun startCallDownload(
|
||||
startTimestamp: Long,
|
||||
author: String
|
||||
): CallDownloadSession {
|
||||
return CallDownloadSessionImpl(
|
||||
context = remoteSideContext,
|
||||
callStartTimestamp = startTimestamp,
|
||||
author = author
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.download
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -11,11 +12,9 @@ import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.google.gson.GsonBuilder
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.job
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
@@ -61,6 +60,14 @@ class DownloadProcessor (
|
||||
private val remoteSideContext: RemoteSideContext,
|
||||
private val callback: DownloadCallback
|
||||
) {
|
||||
companion object {
|
||||
private val downloadSemaphore = Semaphore(3)
|
||||
}
|
||||
|
||||
private data class GallerySaveResult(
|
||||
val uri: Uri,
|
||||
val alreadyDownloaded: Boolean = false
|
||||
)
|
||||
|
||||
private val translation by lazy {
|
||||
remoteSideContext.translation.getCategory("download_processor")
|
||||
@@ -118,78 +125,36 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
|
||||
val fileName = buildOutputFileName(metadata.outputPath, fileType)
|
||||
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
|
||||
if (configuredFolder.isBlank()) {
|
||||
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
|
||||
?: throw Exception("Failed to save media (no output uri)")
|
||||
pendingTask.task.extra = outputUri.toString()
|
||||
pendingTask.success()
|
||||
callbackOnSuccess(fileName)
|
||||
return
|
||||
}
|
||||
val saveResult = if (configuredFolder.isBlank()) {
|
||||
saveToSystemDefault(fileName, fileType, inputFile, metadata)
|
||||
} else {
|
||||
runCatching {
|
||||
saveToConfiguredFolder(
|
||||
configuredFolder = configuredFolder,
|
||||
fileName = fileName,
|
||||
fileType = fileType,
|
||||
inputFile = inputFile,
|
||||
metadata = metadata,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it)
|
||||
}.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)
|
||||
} ?: throw Exception("Failed to save media (no output uri)")
|
||||
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
pendingTask.task.extra = saveResult.uri.toString()
|
||||
pendingTask.success()
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name) ?: folder.createDirectory(name)!!
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
// checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file.
|
||||
outputFileFolder.findFile(fileName)?.let { existingFile ->
|
||||
pendingTask.updateProgress("Comparing existing media")
|
||||
if (existingFile.length() != inputFile.length()) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
|
||||
remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = existingInputStream.read(buffer2)
|
||||
if (read1 != read2 || !buffer1.contentEquals(buffer2)) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingTask.task.extra = existingFile.uri.toString()
|
||||
pendingTask.success()
|
||||
if (saveResult.alreadyDownloaded) {
|
||||
callbackOnFailure(translation["already_downloaded_toast"])
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
pendingTask.task.extra = outputFile.uri.toString()
|
||||
pendingTask.success()
|
||||
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = outputFile.uri
|
||||
data = saveResult.uri
|
||||
})
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to scan media file", it)
|
||||
@@ -205,15 +170,184 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToConfiguredFolder(
|
||||
configuredFolder: String,
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
pendingTask: PendingTask,
|
||||
): GallerySaveResult {
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name)
|
||||
?: folder.createDirectory(name)
|
||||
?: throw Exception("Failed to create output directory $name")
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
outputFileFolder.findFile(fileName)?.let { existingFile ->
|
||||
pendingTask.updateProgress("Comparing existing media")
|
||||
if (existingFile.length() != inputFile.length()) {
|
||||
existingFile.delete()
|
||||
} else {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
?: throw Exception("Failed to open existing media for comparison")
|
||||
|
||||
existingInputStream.use { currentExistingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = currentExistingInputStream.read(buffer2)
|
||||
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
|
||||
existingFile.delete()
|
||||
return@let
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
|
||||
?: throw Exception("Failed to create output file $fileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $fileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
|
||||
for (index in 0 until length) {
|
||||
if (left[index] != right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
|
||||
val rawName = outputPath.substringAfterLast("/").ifBlank { "media" }
|
||||
val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName))
|
||||
val currentExtension = rawName.substringAfterLast(".", "").lowercase()
|
||||
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
|
||||
val extension = if (currentExtension == targetExtension) {
|
||||
currentExtension
|
||||
} else {
|
||||
targetExtension
|
||||
}
|
||||
return "$sanitizedBase.$extension"
|
||||
}
|
||||
|
||||
private fun sanitizeFileName(name: String): String {
|
||||
return name
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
.replace(Regex("\\p{Cntrl}"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.ifBlank { "media" }
|
||||
}
|
||||
|
||||
private fun sanitizeRelativePath(path: String): String {
|
||||
return path.split("/")
|
||||
.mapNotNull { segment ->
|
||||
segment.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::sanitizeFileName)
|
||||
}
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun appendNameSuffix(fileName: String, index: Int): String {
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
val baseName = fileName.substringBeforeLast(".", fileName)
|
||||
return if (extension.isBlank()) {
|
||||
"$baseName ($index)"
|
||||
} else {
|
||||
"$baseName ($index).$extension"
|
||||
}
|
||||
}
|
||||
|
||||
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?"
|
||||
val selectionArgs = arrayOf(fileName, relativePath)
|
||||
return remoteSideContext.androidContext.contentResolver.query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||
ContentUris.withAppendedId(collection, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentMatches(uri: Uri, inputFile: File): Boolean {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false
|
||||
return streamsMatch(existingInputStream, inputFile.inputStream())
|
||||
}
|
||||
|
||||
private fun filesMatch(existingFile: File, inputFile: File): Boolean {
|
||||
return streamsMatch(existingFile.inputStream(), inputFile.inputStream())
|
||||
}
|
||||
|
||||
private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean {
|
||||
existingInputStream.use { currentExistingInputStream ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
var read1: Int
|
||||
var read2: Int
|
||||
|
||||
inputInputStream.use { inputStream ->
|
||||
while (true) {
|
||||
read1 = inputStream.read(buffer1)
|
||||
read2 = currentExistingInputStream.read(buffer2)
|
||||
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
|
||||
return false
|
||||
}
|
||||
if (read1 == -1) break
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveToSystemDefault(
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
): Uri? {
|
||||
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
): GallerySaveResult? {
|
||||
val subPath = sanitizeRelativePath(
|
||||
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
)
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
fileType.isVideo -> Environment.DIRECTORY_MOVIES
|
||||
@@ -228,23 +362,69 @@ class DownloadProcessor (
|
||||
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
}
|
||||
val resolver = remoteSideContext.androidContext.contentResolver
|
||||
val uri = resolver.insert(collection, values) ?: return null
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: return null
|
||||
uri
|
||||
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
if (extension.isBlank()) baseName else "$baseName.$extension"
|
||||
}
|
||||
findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri ->
|
||||
if (contentMatches(existingUri, inputFile)) {
|
||||
remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName")
|
||||
return GallerySaveResult(existingUri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
for (attempt in 0..100) {
|
||||
val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt)
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
val uri = runCatching {
|
||||
resolver.insert(collection, values)
|
||||
}.onFailure {
|
||||
remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}")
|
||||
}.getOrNull() ?: continue
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream for $candidateName")
|
||||
|
||||
ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 0)
|
||||
}.also { resolver.update(uri, it, null, null) }
|
||||
|
||||
return GallerySaveResult(uri)
|
||||
}.onFailure {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it)
|
||||
}
|
||||
}
|
||||
|
||||
throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
|
||||
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
|
||||
destDir.mkdirs()
|
||||
val destFile = File(destDir, fileName)
|
||||
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
if (extension.isBlank()) baseName else "$baseName.$extension"
|
||||
}
|
||||
var destFile = File(destDir, sanitizedFileName)
|
||||
if (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++))
|
||||
}
|
||||
}
|
||||
FileOutputStream(destFile).use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
@@ -255,7 +435,7 @@ class DownloadProcessor (
|
||||
}
|
||||
)
|
||||
}
|
||||
Uri.fromFile(destFile)
|
||||
GallerySaveResult(Uri.fromFile(destFile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +443,7 @@ class DownloadProcessor (
|
||||
return File.createTempFile("media", ".tmp")
|
||||
}
|
||||
|
||||
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
|
||||
val jobs = mutableListOf<Job>()
|
||||
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
|
||||
val downloadedMedias = mutableMapOf<InputMedia, File>()
|
||||
var totalSize = 1L
|
||||
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
|
||||
@@ -277,71 +456,72 @@ class DownloadProcessor (
|
||||
)
|
||||
}
|
||||
|
||||
downloadRequest.inputMedias.forEach { inputMedia ->
|
||||
fun setProgress(progress: String) {
|
||||
inputMediaProgress[inputMedia] = progress
|
||||
updateDownloadProgress()
|
||||
}
|
||||
coroutineScope {
|
||||
downloadRequest.inputMedias.forEach { inputMedia ->
|
||||
fun setProgress(progress: String) {
|
||||
inputMediaProgress[inputMedia] = progress
|
||||
updateDownloadProgress()
|
||||
}
|
||||
|
||||
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
|
||||
createMediaTempFile().apply {
|
||||
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
|
||||
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
|
||||
var read: Int
|
||||
var totalRead = 0L
|
||||
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
|
||||
createMediaTempFile().apply {
|
||||
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
|
||||
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
|
||||
var read: Int
|
||||
var totalRead = 0L
|
||||
|
||||
outputStream().use { outputStream ->
|
||||
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
inputMediaDownloadedBytes[inputMedia] = totalRead
|
||||
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
|
||||
}
|
||||
}
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
|
||||
launch {
|
||||
when (inputMedia.type) {
|
||||
DownloadMediaType.PROTO_MEDIA -> {
|
||||
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
|
||||
totalSize += length
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = length)
|
||||
}
|
||||
})
|
||||
}
|
||||
DownloadMediaType.REMOTE_MEDIA -> {
|
||||
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connect()
|
||||
totalSize += contentLength.toLong()
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = contentLength.toLong())
|
||||
outputStream().use { outputStream ->
|
||||
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
|
||||
outputStream.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
inputMediaDownloadedBytes[inputMedia] = totalRead
|
||||
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadMediaType.DIRECT_MEDIA -> {
|
||||
val decoded = Base64.UrlSafe.decode(inputMedia.content)
|
||||
totalSize += decoded.size.toLong()
|
||||
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
|
||||
}
|
||||
else -> {
|
||||
File(inputMedia.content).inputStream().use {
|
||||
totalSize += it.available().toLong()
|
||||
handleInputStream(it, estimatedSize = it.available().toLong())
|
||||
}.also { downloadedMedias[inputMedia] = it }
|
||||
}
|
||||
|
||||
launch {
|
||||
when (inputMedia.type) {
|
||||
DownloadMediaType.PROTO_MEDIA -> {
|
||||
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
|
||||
totalSize += length
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = length)
|
||||
}
|
||||
})
|
||||
}
|
||||
DownloadMediaType.REMOTE_MEDIA -> {
|
||||
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("User-Agent", Constants.USER_AGENT)
|
||||
connect()
|
||||
totalSize += contentLength.toLong()
|
||||
inputStream.use {
|
||||
handleInputStream(it, estimatedSize = contentLength.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
DownloadMediaType.DIRECT_MEDIA -> {
|
||||
val decoded = Base64.UrlSafe.decode(inputMedia.content)
|
||||
totalSize += decoded.size.toLong()
|
||||
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
|
||||
}
|
||||
else -> {
|
||||
File(inputMedia.content).inputStream().use {
|
||||
totalSize += it.available().toLong()
|
||||
handleInputStream(it, estimatedSize = it.available().toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also { jobs.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
jobs.joinAll()
|
||||
downloadedMedias
|
||||
return downloadedMedias
|
||||
}
|
||||
|
||||
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
|
||||
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
|
||||
downloadRequest.inputMedias.first().let { inputMedia ->
|
||||
val mediaType = inputMedia.type
|
||||
val media = downloadedMedias[inputMedia]!!
|
||||
@@ -352,24 +532,24 @@ class DownloadProcessor (
|
||||
val outputFile = File.createTempFile("voice_note", ".$format")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.CONVERSION,
|
||||
inputs = listOf(media.file.absolutePath),
|
||||
inputs = listOf(media.absolutePath),
|
||||
output = outputFile
|
||||
))
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
saveMediaToGallery(pendingTask, outputFile, metadata)
|
||||
outputFile.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saveMediaToGallery(pendingTask, media.file, metadata)
|
||||
media.file.delete()
|
||||
saveMediaToGallery(pendingTask, media, metadata)
|
||||
media.delete()
|
||||
return
|
||||
}
|
||||
|
||||
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
|
||||
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
|
||||
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
|
||||
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
|
||||
for (i in 0 until baseUrlNodeList.length) {
|
||||
val baseUrlNode = baseUrlNodeList.item(i)
|
||||
@@ -379,7 +559,7 @@ class DownloadProcessor (
|
||||
|
||||
val dashOptions = downloadRequest.dashOptions!!
|
||||
|
||||
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
|
||||
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
|
||||
dashPlaylistFile.outputStream().use {
|
||||
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
|
||||
}
|
||||
@@ -404,7 +584,7 @@ class DownloadProcessor (
|
||||
|
||||
dashPlaylistFile.delete()
|
||||
outputFile.delete()
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,126 +604,130 @@ class DownloadProcessor (
|
||||
// check if the media file has been deleted
|
||||
if (task.type == TaskType.DOWNLOAD) {
|
||||
val outputFile = runCatching {
|
||||
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
|
||||
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
|
||||
}.getOrNull()
|
||||
|
||||
if (outputFile != null && !outputFile.exists()) {
|
||||
return@let
|
||||
}
|
||||
}
|
||||
callbackOnFailure(translation["already_downloaded_toast"], null)
|
||||
callbackOnFailure(translation["already_downloaded_toast"])
|
||||
return@launch
|
||||
} else {
|
||||
callbackOnFailure(translation["already_queued_toast"], null)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
remoteSideContext.log.debug("downloading media")
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = downloadMetadata.downloadSource,
|
||||
author = downloadMetadata.mediaAuthor,
|
||||
hash = downloadMetadata.mediaIdentifier
|
||||
)
|
||||
).apply {
|
||||
status = TaskStatus.RUNNING
|
||||
addListener(PendingTaskListener(onCancel = {
|
||||
coroutineContext.job.cancel()
|
||||
}))
|
||||
updateProgress("Downloading...")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
if (downloadRequest.isAudioStream) {
|
||||
val streamUrl = downloadRequest.inputMedias.first().content
|
||||
val outputFile = File.createTempFile("audio_stream", ".mp3")
|
||||
|
||||
callbackOnProgress("Downloading audio stream")
|
||||
pendingTask.updateProgress("Downloading audio stream")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf(streamUrl),
|
||||
output = outputFile,
|
||||
audioStreamFormat = downloadRequest.audioStreamFormat
|
||||
))
|
||||
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
|
||||
return@launch
|
||||
downloadSemaphore.withPermit {
|
||||
callbackOnProgress(translation["download_started_toast"])
|
||||
remoteSideContext.log.debug("downloading media")
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = downloadMetadata.downloadSource,
|
||||
author = downloadMetadata.mediaAuthor,
|
||||
hash = downloadMetadata.mediaIdentifier
|
||||
)
|
||||
).apply {
|
||||
status = TaskStatus.RUNNING
|
||||
addListener(PendingTaskListener(onCancel = {
|
||||
coroutineContext.job.cancel()
|
||||
}))
|
||||
updateProgress("Downloading...")
|
||||
}
|
||||
|
||||
//first download all input medias into cache
|
||||
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
|
||||
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
|
||||
}.toMap().toMutableMap()
|
||||
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
|
||||
runCatching {
|
||||
if (downloadRequest.isAudioStream) {
|
||||
val streamUrl = downloadRequest.inputMedias.first().content
|
||||
val outputFile = File.createTempFile("audio_stream", ".mp3")
|
||||
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
callbackOnProgress("Downloading audio stream")
|
||||
pendingTask.updateProgress("Downloading audio stream")
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf(streamUrl),
|
||||
output = outputFile,
|
||||
audioStreamFormat = downloadRequest.audioStreamFormat
|
||||
))
|
||||
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
|
||||
return@launch
|
||||
}
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
downloadedMedias.clear()
|
||||
//first download all input medias into cache
|
||||
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
|
||||
it.key to it.value
|
||||
}.toMap().toMutableMap()
|
||||
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
|
||||
|
||||
zipFile.file.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
createMediaTempFile().apply {
|
||||
outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
|
||||
|
||||
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
|
||||
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
downloadedMedias.clear()
|
||||
|
||||
zipFile.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
createMediaTempFile().apply {
|
||||
outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = it
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = DownloadedFile(it, FileType.fromFile(it))
|
||||
}
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) ->
|
||||
value.delete()
|
||||
}
|
||||
|
||||
shouldMergeOverlay = true
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) ->
|
||||
value.file.delete()
|
||||
if (shouldMergeOverlay) {
|
||||
assert(downloadedMedias.size == 2)
|
||||
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
|
||||
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
|
||||
|
||||
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
|
||||
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
|
||||
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_OVERLAY,
|
||||
inputs = listOf(renamedMedia.absolutePath),
|
||||
output = mergedOverlay,
|
||||
overlay = renamedOverlayMedia
|
||||
))
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
remoteSideContext.log.error("Failed to merge overlay", exception)
|
||||
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
|
||||
pendingTask.fail("Failed to merge overlay")
|
||||
}
|
||||
|
||||
mergedOverlay.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}
|
||||
|
||||
shouldMergeOverlay = true
|
||||
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
|
||||
}.onFailure { exception ->
|
||||
pendingTask.fail("Failed to download media")
|
||||
remoteSideContext.log.error("Failed to download media", exception)
|
||||
callbackOnFailure(translation["failed_generic_toast"], exception.message)
|
||||
}
|
||||
|
||||
if (shouldMergeOverlay) {
|
||||
assert(downloadedMedias.size == 2)
|
||||
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
|
||||
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
|
||||
|
||||
val renamedMedia = renameFromFileType(media.file, media.fileType)
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
|
||||
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
|
||||
|
||||
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_OVERLAY,
|
||||
inputs = listOf(renamedMedia.absolutePath),
|
||||
output = mergedOverlay,
|
||||
overlay = renamedOverlayMedia
|
||||
))
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
|
||||
}.onFailure { exception ->
|
||||
if (coroutineContext.job.isCancelled) return@onFailure
|
||||
remoteSideContext.log.error("Failed to merge overlay", exception)
|
||||
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
|
||||
pendingTask.fail("Failed to merge overlay")
|
||||
}
|
||||
|
||||
mergedOverlay.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}
|
||||
|
||||
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
|
||||
}.onFailure { exception ->
|
||||
pendingTask.fail("Failed to download media")
|
||||
remoteSideContext.log.error("Failed to download media", exception)
|
||||
callbackOnFailure(translation["failed_generic_toast"], exception.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ class FFMpegProcessor(
|
||||
pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})")
|
||||
}
|
||||
)
|
||||
|
||||
fun newFFMpegProcessor(context: RemoteSideContext, onStatistics: (Statistics) -> Unit = {}) = FFMpegProcessor(
|
||||
logManager = context.log,
|
||||
ffmpegOptions = context.config.root.downloader.ffmpegOptions,
|
||||
onStatistics = onStatistics
|
||||
)
|
||||
}
|
||||
enum class Action {
|
||||
DOWNLOAD_DASH,
|
||||
@@ -66,6 +72,7 @@ class FFMpegProcessor(
|
||||
CONVERSION,
|
||||
MERGE_MEDIA,
|
||||
DOWNLOAD_AUDIO_STREAM,
|
||||
MERGE_AUDIO_STREAMS,
|
||||
}
|
||||
|
||||
data class Request(
|
||||
@@ -76,6 +83,7 @@ class FFMpegProcessor(
|
||||
val startTime: Long? = null, //only for DOWNLOAD_DASH
|
||||
val duration: Long? = null, //only for DOWNLOAD_DASH
|
||||
val audioStreamFormat: AudioStreamFormat? = null, //only for DOWNLOAD_AUDIO_STREAM
|
||||
val inputDelayOffsets: Map<String, Long>? = null, // only for MERGE_AUDIO_STREAMS
|
||||
|
||||
var videoCodec: String? = null,
|
||||
var audioCodec: String? = null,
|
||||
@@ -98,7 +106,18 @@ class FFMpegProcessor(
|
||||
if (session.returnCode.isValueSuccess) {
|
||||
Result.success(session)
|
||||
} else {
|
||||
Result.failure(Exception(session.output))
|
||||
val output = session.output
|
||||
val errorMsg = when {
|
||||
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
|
||||
else -> {
|
||||
val lines = output.lines().filter { line ->
|
||||
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
|
||||
}
|
||||
lines.lastOrNull()?.take(400)
|
||||
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
|
||||
}
|
||||
}
|
||||
Result.failure(Exception(errorMsg))
|
||||
}
|
||||
)
|
||||
}, logFunction@{ log ->
|
||||
@@ -127,7 +146,7 @@ class FFMpegProcessor(
|
||||
|
||||
val outputArguments = ArgumentList().apply {
|
||||
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
|
||||
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
|
||||
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
@@ -202,7 +221,7 @@ class FFMpegProcessor(
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-map" to "\"[vout]\""
|
||||
|
||||
@@ -220,8 +239,28 @@ class FFMpegProcessor(
|
||||
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
||||
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
||||
}
|
||||
Action.MERGE_AUDIO_STREAMS -> {
|
||||
inputArguments.clear()
|
||||
outputArguments.clear()
|
||||
val filterParts = StringBuilder()
|
||||
args.inputs.forEachIndexed { index, input ->
|
||||
inputArguments += "-i" to input
|
||||
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
||||
if (offset > 0) {
|
||||
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
|
||||
} else {
|
||||
filterParts.append("[$index:a]acopy[a$index];")
|
||||
}
|
||||
}
|
||||
args.inputs.indices.forEach { index ->
|
||||
filterParts.append("[a$index]")
|
||||
}
|
||||
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
|
||||
outputArguments += "-filter_complex" to "\"$filterParts\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package me.eternal.purrfectsnap.download.call
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.bridge.DownloadCallback
|
||||
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
|
||||
import me.eternal.purrfectsnap.common.data.download.AudioStreamFormat
|
||||
import me.eternal.purrfectsnap.common.data.download.DownloadMetadata
|
||||
import me.eternal.purrfectsnap.common.data.download.MediaDownloadSource
|
||||
import me.eternal.purrfectsnap.common.data.download.createNewFilePath
|
||||
import me.eternal.purrfectsnap.download.DownloadProcessor
|
||||
import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.task.PendingTaskListener
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
class CallDownloadSessionImpl(
|
||||
private val context: RemoteSideContext,
|
||||
private val callStartTimestamp: Long,
|
||||
private val author: String,
|
||||
): CallDownloadSession.Stub() {
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var callEnded: Boolean = false
|
||||
|
||||
private val streams = CopyOnWriteArrayList<CallStream>()
|
||||
private var mergeJob: Job? = null
|
||||
|
||||
init {
|
||||
context.log.verbose("Starting call callStartTimestamp=$callStartTimestamp")
|
||||
}
|
||||
|
||||
inner class CallStream(
|
||||
val startTimestampMillis: Long,
|
||||
private val audioStreamFormat: AudioStreamFormat
|
||||
) {
|
||||
val job: Job
|
||||
val writePfd: ParcelFileDescriptor
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
|
||||
if (exists()) delete()
|
||||
}
|
||||
|
||||
init {
|
||||
val pipe = ParcelFileDescriptor.createPipe()
|
||||
writePfd = pipe[1]
|
||||
|
||||
job = coroutineScope.launch {
|
||||
runCatching {
|
||||
FFMpegProcessor.newFFMpegProcessor(context).execute(
|
||||
FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
|
||||
inputs = listOf("/proc/self/fd/${pipe[0].fd}"),
|
||||
output = outputFile,
|
||||
audioStreamFormat = audioStreamFormat
|
||||
)
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Error converting call audio stream", it)
|
||||
runCatching {
|
||||
outputFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
pipe.forEach { runCatching { it.close() } }
|
||||
|
||||
context.log.verbose("Call stream ended startTimestampMillis=$startTimestampMillis")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun createStream(
|
||||
startTimestampMillis: Long,
|
||||
channels: Int,
|
||||
sampleRate: Int,
|
||||
encoding: Int
|
||||
): ParcelFileDescriptor? {
|
||||
if (callEnded) return null
|
||||
|
||||
context.log.verbose("createFileDescriptor startTimestampMillis=$startTimestampMillis, channels=$channels, sampleRate=$sampleRate, encoding=$encoding")
|
||||
val callStream = CallStream(
|
||||
startTimestampMillis = startTimestampMillis,
|
||||
audioStreamFormat = AudioStreamFormat(
|
||||
channels = channels,
|
||||
sampleRate = sampleRate,
|
||||
encoding = encoding
|
||||
)
|
||||
)
|
||||
|
||||
synchronized(streams) {
|
||||
streams.add(callStream)
|
||||
}
|
||||
|
||||
return callStream.writePfd
|
||||
}
|
||||
|
||||
override fun end() {
|
||||
if (callEnded) return
|
||||
callEnded = true
|
||||
|
||||
if (streams.isEmpty()) {
|
||||
context.log.verbose("No call chunks to merge")
|
||||
return
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
|
||||
val dateString = dateFormat.format(Date(callStartTimestamp))
|
||||
val finalFileName = "Call_${author}_$dateString"
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("${finalFileName}_final.mp3")
|
||||
val pendingTask = context.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = context.translation.format("task_call_recording_title", "author" to author),
|
||||
author = author,
|
||||
hash = UUID.randomUUID().toString()
|
||||
)
|
||||
).apply {
|
||||
addListener(PendingTaskListener(
|
||||
onCancel = {
|
||||
mergeJob?.cancel()
|
||||
outputFile.delete()
|
||||
streams.forEach { stream ->
|
||||
stream.outputFile.delete()
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
mergeJob = coroutineScope.launch {
|
||||
try {
|
||||
streams.forEach { stream ->
|
||||
stream.job.join()
|
||||
}
|
||||
|
||||
val sortedStreams = streams.filter { it.outputFile.exists() }.sortedBy { it.startTimestampMillis }
|
||||
if (sortedStreams.isEmpty()) {
|
||||
pendingTask.fail("No recorded audio data")
|
||||
return@launch
|
||||
}
|
||||
|
||||
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
|
||||
FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_AUDIO_STREAMS,
|
||||
inputs = sortedStreams.map { it.outputFile.absolutePath },
|
||||
output = outputFile,
|
||||
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp).coerceAtLeast(0L) }
|
||||
)
|
||||
)
|
||||
|
||||
DownloadProcessor(context, object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String) {
|
||||
context.log.verbose("Downloaded call $outputPath")
|
||||
context.shortToast(context.translation["features.properties.downloader.properties.call_recorder.properties.call_recording_saved_toast"])
|
||||
}
|
||||
}).saveMediaToGallery(pendingTask, outputFile, DownloadMetadata(
|
||||
mediaIdentifier = UUID.randomUUID().toString(),
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
finalFileName,
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL,
|
||||
mediaAuthor = author,
|
||||
creationTimestamp = System.currentTimeMillis()
|
||||
),
|
||||
mediaAuthor = author,
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL.translate(context.translation),
|
||||
iconUrl = null
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
context.log.error("Failed to merge call recording", e)
|
||||
pendingTask.fail("Merge failed: ${e.message}")
|
||||
} finally {
|
||||
streams.forEach { stream ->
|
||||
stream.outputFile.delete()
|
||||
}
|
||||
outputFile.delete()
|
||||
}
|
||||
|
||||
context.log.verbose("ending call")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.setup.patch
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -29,7 +30,7 @@ class AutoPatchServer(
|
||||
|
||||
fun fetchLatestSnapchatApk(): LatestApk? {
|
||||
val request = Request.Builder()
|
||||
.url("https://api.github.com/repos/particle-box/auto-patch-server/releases/latest")
|
||||
.url("https://api.github.com/repos/particle-box/download-snap/releases/latest")
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
@@ -47,9 +48,9 @@ class AutoPatchServer(
|
||||
name to downloadUrl
|
||||
}
|
||||
|
||||
val selected = apkAssets.firstOrNull { it.first.contains("snapchat", ignoreCase = true) }
|
||||
?: apkAssets.firstOrNull()
|
||||
?: return null
|
||||
val nonPrimary = apkAssets.filterNot { it.first.contains("snapchat", ignoreCase = true) }
|
||||
val selectionPool = if (nonPrimary.isNotEmpty()) nonPrimary else apkAssets
|
||||
val selected = selectionPool.random(Random.Default)
|
||||
|
||||
return LatestApk(
|
||||
tagName = tagName,
|
||||
@@ -59,4 +60,3 @@ class AutoPatchServer(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ data class PatchConfig(
|
||||
) {
|
||||
data class LSPConfig(
|
||||
var API_CODE: Int = 93,
|
||||
var VERSION_CODE: Int = 360,
|
||||
var VERSION_NAME: String = "0.5.1",
|
||||
var CORE_VERSION_CODE: Int = 6649,
|
||||
var CORE_VERSION_NAME: String = "1.8.5",
|
||||
var VERSION_CODE: Int = 430,
|
||||
var VERSION_NAME: String = "0.7",
|
||||
var CORE_VERSION_CODE: Int = 7137,
|
||||
var CORE_VERSION_NAME: String = "1.10.1",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceMessagingData(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
executeAsync {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
val friendIds = friends.map { it.userId }.toSet()
|
||||
val groupIds = groups.map { it.conversationId }.toSet()
|
||||
|
||||
getFriends().forEach { friend ->
|
||||
if (friend.userId !in friendIds) {
|
||||
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
|
||||
}
|
||||
}
|
||||
|
||||
getGroups().forEach { group ->
|
||||
if (group.conversationId !in groupIds) {
|
||||
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
friends.forEach { friend ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
)
|
||||
)
|
||||
|
||||
friend.streaks?.takeIf { it.length > 0 }?.also {
|
||||
val streaks = getFriendStreaks(friend.userId)
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
streaks?.notify != false,
|
||||
it.expirationTimestamp,
|
||||
it.length
|
||||
)
|
||||
)
|
||||
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
return database.rawQuery(
|
||||
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.MainActivity
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
|
||||
class AnnouncementCheckWorker(
|
||||
private val appContext: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return runCatching {
|
||||
val url = inputData.getString("announcements_url") ?: return Result.failure()
|
||||
val body = fetchBody(url)?.trim().orEmpty()
|
||||
if (body.isEmpty()) return Result.success()
|
||||
|
||||
val hash = sha256(body)
|
||||
val prefs = appContext.getSharedPreferences("prefs", 0)
|
||||
val lastHash = prefs.getString("announcements_last_hash", null)
|
||||
if (lastHash == null) {
|
||||
prefs.edit().putString("announcements_last_hash", hash).apply()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (lastHash != hash) {
|
||||
prefs.edit().putString("announcements_last_hash", hash).apply()
|
||||
showAnnouncementNotification()
|
||||
}
|
||||
Result.success()
|
||||
}.getOrElse {
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchBody(url: String): String? {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
return response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256(text: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
|
||||
private fun showAnnouncementNotification() {
|
||||
val channelId = "purrfectsnap_announcements"
|
||||
val name = inputData.getString("channel_name") ?: "Announcements"
|
||||
val descriptionText = inputData.getString("channel_description") ?: "Notifications for PurrfectSnap announcements"
|
||||
val title = inputData.getString("notification_title") ?: "New announcement available"
|
||||
val text = inputData.getString("notification_text") ?: "Tap to open and read."
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val intent = Intent(appContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra("route", "home")
|
||||
putExtra("show_announcements", true)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
}
|
||||
with(NotificationManagerCompat.from(appContext)) {
|
||||
notify(2, builder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,15 +127,26 @@ class TaskManager(
|
||||
|
||||
fun getTaskByHash(hash: String?): Task? {
|
||||
if (hash == null) return null
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
return readTaskFromCursor(cursor)
|
||||
return runBlocking {
|
||||
suspendCoroutine { continuation ->
|
||||
queueExecutor.execute {
|
||||
runCatching {
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
|
||||
} else {
|
||||
continuation.resumeWith(Result.success(null))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
continuation.resumeWith(Result.failure(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getActiveTasks() = activeTasks
|
||||
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
|
||||
|
||||
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
|
||||
val tasks = mutableMapOf<Long, Task>()
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.work.WorkerParameters
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.MainActivity
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
|
||||
|
||||
class UpdateCheckWorker(
|
||||
private val appContext: Context,
|
||||
@@ -24,7 +25,11 @@ class UpdateCheckWorker(
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
val latestRelease = Updater.latestRelease
|
||||
val channel = when (inputData.getString("update_channel")) {
|
||||
"prerelease" -> Channel.PRERELEASE
|
||||
else -> Channel.STABLE
|
||||
}
|
||||
val latestRelease = Updater.getLatestRelease(channel)
|
||||
if (latestRelease != null) {
|
||||
showUpdateNotification(latestRelease.versionName)
|
||||
}
|
||||
@@ -54,6 +59,8 @@ class UpdateCheckWorker(
|
||||
|
||||
val intent = Intent(appContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra("show_changelog", true)
|
||||
putExtra("changelog_version", versionName)
|
||||
}
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -10,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,23 +24,29 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.common.ui.ThemeMode
|
||||
import me.eternal.purrfectsnap.common.ui.ThemePreferences
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
|
||||
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
|
||||
import android.content.IntentFilter
|
||||
|
||||
@@ -61,6 +69,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (::navController.isInitialized.not()) return
|
||||
handleAnnouncementIntent(intent)
|
||||
handleUpdateIntent(intent)
|
||||
|
||||
intent.getStringExtra("route")?.let { route ->
|
||||
navController.popBackStack()
|
||||
@@ -88,6 +98,8 @@ class MainActivity : ComponentActivity() {
|
||||
activity = this@MainActivity
|
||||
checkForRequirements()
|
||||
}
|
||||
handleAnnouncementIntent(intent)
|
||||
handleUpdateIntent(intent)
|
||||
val routes = Routes(managerContext)
|
||||
routes.activityLauncher = ActivityLauncherHelper(this)
|
||||
routes.getRoutes().forEach { it.init() }
|
||||
@@ -112,6 +124,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
AppMaterialTheme(themeMode = themeMode) {
|
||||
val shouldShowAbiWarning = remember {
|
||||
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
|
||||
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
|
||||
val appIsArm64 = libDir.contains("arm64")
|
||||
deviceIsArm64 && !appIsArm64
|
||||
}
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val isLight = background.luminance() > 0.5f
|
||||
val view = LocalView.current
|
||||
@@ -174,7 +211,32 @@ class MainActivity : ComponentActivity() {
|
||||
} else {
|
||||
PaddingValues(0.dp)
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
navigation.NavContent(contentPadding, startDestination)
|
||||
|
||||
// Theme Reveal Overlay
|
||||
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
|
||||
CircularRevealOverlay(
|
||||
context = managerContext,
|
||||
request = revealRequest,
|
||||
onComplete = { navigation.themeRevealState.clearReveal() }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(statusBarHeight + 24.dp)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF241F52),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
if (!isFullscreen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -209,4 +271,22 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(restartReceiver)
|
||||
}
|
||||
|
||||
private fun handleAnnouncementIntent(intent: Intent) {
|
||||
if (intent.getBooleanExtra("show_announcements", false)) {
|
||||
applicationContext.getSharedPreferences("prefs", 0).edit()
|
||||
.putBoolean("show_announcements_on_launch", true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateIntent(intent: Intent) {
|
||||
if (intent.getBooleanExtra("show_changelog", false)) {
|
||||
val version = intent.getStringExtra("changelog_version")
|
||||
applicationContext.getSharedPreferences("prefs", 0).edit()
|
||||
.putBoolean("show_changelog_on_launch", true)
|
||||
.putString("changelog_version_on_launch", version)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion.AphelionTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.themes.legacy.LegacyTheme
|
||||
|
||||
/**
|
||||
* ManagerTheme is the registry of all available themes.
|
||||
*
|
||||
* Adding a new theme requires changes only in this file:
|
||||
* - Add a new object entry to the sealed class
|
||||
* - Add its ID string to fromId()
|
||||
*
|
||||
* No other file needs to change when a new theme is added.
|
||||
*/
|
||||
sealed class ManagerTheme(val theme: ThemeContract) {
|
||||
object Legacy : ManagerTheme(LegacyTheme)
|
||||
object Aphelion : ManagerTheme(AphelionTheme)
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ManagerTheme = when (id) {
|
||||
"APHELION" -> Aphelion
|
||||
else -> Legacy // default fallback is always Legacy
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -105,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.navigation
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
|
||||
import kotlin.math.round
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
@@ -116,28 +116,44 @@ import kotlin.math.sin
|
||||
androidx.compose.animation.ExperimentalAnimationApi::class
|
||||
)
|
||||
class Navigation(
|
||||
private val context: RemoteSideContext,
|
||||
internal val context: RemoteSideContext,
|
||||
private val navController: NavHostController,
|
||||
val routes: Routes = Routes(context).also { it.navController = navController }
|
||||
) {
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
var globalScrollOffset by mutableIntStateOf(0)
|
||||
val themeRevealState = ThemeRevealState()
|
||||
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
|
||||
|
||||
val canGoBack = remember(navBackStackEntry) {
|
||||
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
|
||||
}
|
||||
val haptic = LocalHapticFeedback.current
|
||||
TopAppBar(
|
||||
modifier = Modifier.height(headerHeight),
|
||||
title = {
|
||||
currentRoute?.apply {
|
||||
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = 1f - (focusFactor * 0.05f)
|
||||
scaleY = 1f - (focusFactor * 0.05f)
|
||||
translationY = (-2 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -146,11 +162,20 @@ class Navigation(
|
||||
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = backButtonAnimation }
|
||||
.graphicsLayer {
|
||||
alpha = backButtonAnimation
|
||||
scaleX = 1f - (focusFactor * 0.1f)
|
||||
scaleY = 1f - (focusFactor * 0.1f)
|
||||
}
|
||||
.width(lerp(0.dp, 48.dp, backButtonAnimation))
|
||||
.height(48.dp)
|
||||
) {
|
||||
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
|
||||
IconButton(onClick = {
|
||||
if (canGoBack) {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
navController.popBackStack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
@@ -162,21 +187,34 @@ class Navigation(
|
||||
actions = {
|
||||
currentRoute?.topBarActions?.invoke(this)
|
||||
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
|
||||
IconButton(onClick = { openBottomBarCustomization = true }) {
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
openBottomBarCustomization = true
|
||||
}) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingBottomBar() {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
val availableRoutes = remember {
|
||||
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
|
||||
}
|
||||
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val barHeight = lerp(82.dp, 64.dp, focusFactor)
|
||||
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val iconTranslationY = (10 * focusFactor).dp
|
||||
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
|
||||
fun loadSelected(): List<String> {
|
||||
@@ -233,9 +271,17 @@ class Navigation(
|
||||
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
|
||||
Surface(
|
||||
shape = barShape,
|
||||
color = Color.Transparent,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
|
||||
.drawBehind {
|
||||
@@ -263,7 +309,7 @@ class Navigation(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.height(barHeight)
|
||||
.clip(barShape)
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.border(BorderStroke(1.dp, barBorder), barShape)
|
||||
@@ -296,16 +342,16 @@ class Navigation(
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().height(82.dp)) {
|
||||
Box(Modifier.fillMaxWidth().height(barHeight)) {
|
||||
var barWidthPx by remember { mutableStateOf(0f) }
|
||||
val itemCount = selectedRoutes.size.coerceAtLeast(1)
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val selectedIndex = remember(currentRoute, selectedRoutes) {
|
||||
val index = selectedRoutes.indexOf(currentRoute)
|
||||
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
|
||||
if (index >= 0) index else null
|
||||
}
|
||||
|
||||
selectedIndex?.let { // Null check
|
||||
selectedIndex?.let {
|
||||
val itemWidthPx =
|
||||
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
|
||||
val offsetAnim = remember { Animatable(0f) }
|
||||
@@ -332,8 +378,7 @@ class Navigation(
|
||||
}
|
||||
val horizontalInset = 2.dp
|
||||
val indicatorWidth = (with(density) { itemWidthPx.toDp() } - horizontalInset * 2)
|
||||
.coerceAtLeast(70.dp)
|
||||
.coerceAtMost(with(density) { itemWidthPx.toDp() })
|
||||
.coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -370,7 +415,7 @@ class Navigation(
|
||||
.fillMaxHeight()
|
||||
.width(indicatorWidth.coerceAtLeast(0.dp))
|
||||
.offset(x = offsetX)
|
||||
.padding(vertical = 10.dp, horizontal = 2.dp)
|
||||
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
|
||||
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
|
||||
) {
|
||||
Box(
|
||||
@@ -431,7 +476,10 @@ class Navigation(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(22.dp + 2.dp * selectionProgress)
|
||||
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
|
||||
.graphicsLayer {
|
||||
alpha = 0.65f + 0.35f * selectionProgress
|
||||
translationY = iconTranslationY.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
label = {
|
||||
@@ -442,11 +490,15 @@ class Navigation(
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
|
||||
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
|
||||
maxLines = if (isLong) 2 else 1,
|
||||
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
|
||||
softWrap = isLong,
|
||||
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
|
||||
.graphicsLayer {
|
||||
alpha = labelAlpha
|
||||
translationY = (-10 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
selected = isSelected,
|
||||
@@ -457,7 +509,10 @@ class Navigation(
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.72f),
|
||||
indicatorColor = Color.Transparent
|
||||
),
|
||||
onClick = { route.navigateReset() }
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
route.navigateReset()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -546,7 +601,7 @@ class Navigation(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticCustom = LocalHapticFeedback.current
|
||||
var draggingId by remember { mutableStateOf<String?>(null) }
|
||||
var dragDelta by remember { mutableStateOf(0f) }
|
||||
var dragStartIndex by remember { mutableStateOf(-1) }
|
||||
@@ -578,7 +633,7 @@ class Navigation(
|
||||
draggingId = id
|
||||
dragStartIndex = selectedTabIds.indexOf(id)
|
||||
dragDelta = 0f
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDrag = { _: PointerInputChange, dragAmount ->
|
||||
dragDelta += dragAmount.y
|
||||
@@ -592,7 +647,7 @@ class Navigation(
|
||||
list.add(targetIndex, id)
|
||||
selectedTabIds = list
|
||||
saveSelected(selectedTabIds)
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -664,7 +719,7 @@ class Navigation(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
) {
|
||||
availableRoutes.forEach { route ->
|
||||
val id = route.routeInfo.id
|
||||
val already = selectedTabIds.contains(id)
|
||||
@@ -795,7 +850,9 @@ class Navigation(
|
||||
} else {
|
||||
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
|
||||
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
|
||||
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
|
||||
children.forEach { child ->
|
||||
composable(child.routeInfo.id) { child.content.invoke(it) }
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun rememberRouteScrollState(key: String): ScrollState {
|
||||
val scrollState = remember(key) {
|
||||
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
|
||||
}
|
||||
LaunchedEffect(key, scrollState.value) {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
DisposableEffect(key, scrollState) {
|
||||
onDispose {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberRouteLazyListState(key: String): LazyListState {
|
||||
val savedState = RouteStateCache.lazyListOffsets[key]
|
||||
val listState = remember(key) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = savedState?.first ?: 0,
|
||||
firstVisibleItemScrollOffset = savedState?.second ?: 0
|
||||
)
|
||||
}
|
||||
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
DisposableEffect(key, listState) {
|
||||
onDispose {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
return listState
|
||||
}
|
||||
|
||||
private object RouteStateCache {
|
||||
val scrollOffsets = mutableMapOf<String, Int>()
|
||||
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.ManageRuleFeature
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.RetroGameScreen
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.location.BetterLocationRoot
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.LoggedStories
|
||||
@@ -52,7 +54,7 @@ class Routes(
|
||||
) {
|
||||
companion object {
|
||||
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}&includeSavedLocations={includeSavedLocations}"
|
||||
const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
|
||||
const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
|
||||
const val VIEW_LOGGER_HISTORY_ROUTE = "view_logger_history/{uri}"
|
||||
@@ -67,8 +69,8 @@ class Routes(
|
||||
var friendTrackerConfigJsonForImport: String? = null
|
||||
var onRuleImported: (() -> Unit)? = null
|
||||
|
||||
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
|
||||
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
|
||||
val configImportConfirmation = route(RouteInfo(CONFIG_IMPORT_CONFIRMATION_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigImportConfirmationScreen())
|
||||
val configExportSummary = route(RouteInfo(CONFIG_EXPORT_SUMMARY_ROUTE, hasOwnTopBar = true), me.eternal.purrfectsnap.ui.manager.pages.features.ConfigExportSummaryScreen())
|
||||
|
||||
val tasks = route(RouteInfo("tasks", icon = Icons.Default.TaskAlt, primary = true, hasOwnTopBar = true), TasksRootSection())
|
||||
|
||||
@@ -76,6 +78,8 @@ class Routes(
|
||||
val manageRuleFeature = route(RouteInfo("manage_rule_feature/?rule_type={rule_type}", hasOwnTopBar = true), ManageRuleFeature()).parent(features)
|
||||
|
||||
val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true, hasOwnTopBar = true), HomeRootSection())
|
||||
val about = route(RouteInfo("home_about", hasOwnTopBar = true), HomeAbout()).parent(home)
|
||||
val retroGame = route(RouteInfo("retro_game", hasOwnTopBar = true), RetroGameScreen()).parent(home)
|
||||
val settings = route(RouteInfo("home_settings", hasOwnTopBar = true), HomeSettings()).parent(home)
|
||||
val homeLogs = route(RouteInfo("home_logs", hasOwnTopBar = true), HomeLogs()).parent(home)
|
||||
val loggerHistory = route(RouteInfo("logger_history", hasOwnTopBar = true), LoggerHistoryRoot()).parent(home)
|
||||
@@ -87,7 +91,7 @@ class Routes(
|
||||
val friendTrackerCatalog = route(RouteInfo("friend_tracker_catalog", hasOwnTopBar = true), FriendTrackerCatalog())
|
||||
val manageFriendTrackerRepos = route(RouteInfo("manage_friend_tracker_repos", hasOwnTopBar = true), ManageFriendTrackerReposSection())
|
||||
|
||||
val fileImports = route(RouteInfo("file_imports"), FileImportsRoot()).parent(home)
|
||||
val fileImports = route(RouteInfo("file_imports", hasOwnTopBar = true), FileImportsRoot()).parent(home)
|
||||
val manageRepos = route(RouteInfo("manage_repos/?type={type}"), ManageReposSection())
|
||||
|
||||
val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true, hasOwnTopBar = true), SocialRootSection())
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
|
||||
/**
|
||||
* ThemeContract defines the visual layout contract every theme must fulfill.
|
||||
*/
|
||||
interface ThemeContract {
|
||||
@Composable fun HomeRootSection.HomeScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeSettings.SettingsScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeAbout.AboutScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeLogs.LogsScreen(nav: NavBackStackEntry)
|
||||
@Composable fun SocialRootSection.SocialScreen(nav: NavBackStackEntry)
|
||||
@Composable fun TasksRootSection.TasksScreen(nav: NavBackStackEntry)
|
||||
@Composable fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry)
|
||||
@Composable fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry)
|
||||
@Composable fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry)
|
||||
}
|
||||
@@ -62,7 +62,9 @@ fun AestheticDialog(
|
||||
loading: Boolean = false,
|
||||
opaque: Boolean = false,
|
||||
showCloseButton: Boolean = true,
|
||||
confirmEnabled: Boolean = true
|
||||
confirmEnabled: Boolean = true,
|
||||
showIcon: Boolean = true,
|
||||
showTitle: Boolean = true
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { visible = true }
|
||||
@@ -94,28 +96,32 @@ fun AestheticDialog(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
if (showIcon) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(62.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
if (showTitle && title.isNotBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (text.isNotBlank()) {
|
||||
Text(
|
||||
text = text,
|
||||
@@ -130,25 +136,27 @@ fun AestheticDialog(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
if (dismissButtonText != null && onDismiss != null) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) { Text(dismissButtonText) }
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = confirmEnabled && !loading,
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
) { Text(dismissButtonText) }
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
enabled = confirmEnabled && !loading,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.75f)
|
||||
)
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
@@ -1,125 +1,284 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
val container: Color,
|
||||
val border: Brush
|
||||
val borderStart: Color,
|
||||
val borderEnd: Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
return remember {
|
||||
FloatingTopBarColors(
|
||||
container = Color.White.copy(alpha = 0.07f),
|
||||
border = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
container = Color.White.copy(alpha = 0.12f),
|
||||
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Floating Top Bar for Aphelion.
|
||||
* Handles the signature morphing animation and provides a "Bottom Content" slot.
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingTopBar(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollOffset: Int = 0,
|
||||
enableMorph: Boolean = false,
|
||||
containerAlpha: Float = 1f,
|
||||
titleAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = colors.container,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val focusFactor by remember(scrollOffset, enableMorph) {
|
||||
derivedStateOf {
|
||||
if (!enableMorph) 0f
|
||||
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val morphingParams by remember(focusFactor, statusBarHeight) {
|
||||
derivedStateOf {
|
||||
object {
|
||||
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
|
||||
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
|
||||
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
|
||||
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
|
||||
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
|
||||
val topCorners = lerp(26.dp, 0.dp, focusFactor)
|
||||
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
|
||||
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
|
||||
val iconScale = 1f - (0.12f * focusFactor)
|
||||
val horizontalShift = (6 * focusFactor).dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hasSnapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(focusFactor) {
|
||||
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasSnapped = true
|
||||
} else if (focusFactor < 0.5f) {
|
||||
hasSnapped = false
|
||||
}
|
||||
}
|
||||
|
||||
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
|
||||
RoundedCornerShape(
|
||||
topStart = morphingParams.topCorners,
|
||||
topEnd = morphingParams.topCorners,
|
||||
bottomStart = morphingParams.bottomCorners,
|
||||
bottomEnd = morphingParams.bottomCorners
|
||||
)
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
val refractiveColor = remember { Color(0xFF241F52) }
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
|
||||
1.0f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.graphicsLayer {
|
||||
alpha = containerAlpha
|
||||
},
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = (4 * focusFactor).dp
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
|
||||
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
.drawBehind {
|
||||
val strokeWidth = 1.dp.toPx()
|
||||
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
|
||||
val tr = morphingParams.topCorners.toPx()
|
||||
val br = morphingParams.bottomCorners.toPx()
|
||||
|
||||
if (focusFactor > 0.9f) {
|
||||
uPath.reset()
|
||||
uPath.apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(0f, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
|
||||
lineTo(size.width - br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
|
||||
lineTo(size.width, 0f)
|
||||
}
|
||||
drawPath(uPath, brush, style = Stroke(strokeWidth))
|
||||
} else {
|
||||
borderPath.reset()
|
||||
borderPath.apply {
|
||||
moveTo(tr, 0f)
|
||||
lineTo(size.width - tr, 0f)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
|
||||
lineTo(size.width, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
|
||||
lineTo(br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
|
||||
lineTo(0f, tr)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
|
||||
}
|
||||
drawPath(borderPath, brush, style = Stroke(strokeWidth))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.CenterEnd) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = actions
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
bottomContent(focusFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import com.tonyodev.fetch2.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -13,15 +13,19 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object UpdateDownloader {
|
||||
private const val TAG = "UpdateDownloader"
|
||||
private var fetch: Fetch? = null
|
||||
private var listener: FetchListener? = null
|
||||
private val fallbackHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
private fun getInstance(context: Context): Fetch {
|
||||
private fun getInstance(context: RemoteSideContext): Fetch {
|
||||
fetch?.let { return it }
|
||||
fetch = run {
|
||||
val fetchConfiguration = FetchConfiguration.Builder(context.applicationContext)
|
||||
val fetchConfiguration = FetchConfiguration.Builder(context.androidContext)
|
||||
.setDownloadConcurrentLimit(3)
|
||||
.build()
|
||||
Fetch.getInstance(fetchConfiguration)
|
||||
@@ -56,27 +60,185 @@ object UpdateDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDownloadedApk(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadedFile: File
|
||||
): File {
|
||||
val context = remoteContext.androidContext
|
||||
if (downloadedFile.extension.equals("zip", ignoreCase = true)) {
|
||||
val unzipDir = File(context.externalCacheDir, "update")
|
||||
if (unzipDir.exists()) unzipDir.deleteRecursively()
|
||||
unzipDir.mkdirs()
|
||||
remoteContext.log.info(
|
||||
"Extracting update archive ${downloadedFile.absolutePath} -> ${unzipDir.absolutePath}",
|
||||
TAG
|
||||
)
|
||||
unzip(downloadedFile, unzipDir)
|
||||
return unzipDir.walk().firstOrNull { it.isFile && it.extension.equals("apk", true) }
|
||||
?: throw IllegalStateException("No APK found in the downloaded archive")
|
||||
}
|
||||
|
||||
if (!downloadedFile.extension.equals("apk", ignoreCase = true)) {
|
||||
remoteContext.log.warn(
|
||||
"Downloaded file is not an APK or ZIP (${downloadedFile.name}); attempting installation anyway.",
|
||||
TAG
|
||||
)
|
||||
}
|
||||
return downloadedFile
|
||||
}
|
||||
|
||||
private fun scheduleReset(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
downloadProgress.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun installDownloadedFile(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadedFile: File,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
runCatching {
|
||||
remoteContext.log.info(
|
||||
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
|
||||
TAG
|
||||
)
|
||||
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
|
||||
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
val installIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
|
||||
context.startActivity(installIntent)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(30_000)
|
||||
runCatching { downloadedFile.delete() }
|
||||
apkFile.parentFile
|
||||
?.takeIf { it.name == "update" }
|
||||
?.let { dir -> runCatching { dir.deleteRecursively() } }
|
||||
remoteContext.log.info("Cleaned downloaded update files", TAG)
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
|
||||
remoteContext.log.error("Failed to install downloaded update", it, TAG)
|
||||
downloadState.value = DownloadState.FAILED
|
||||
}
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun failDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
scope: CoroutineScope,
|
||||
errorMessage: String,
|
||||
throwable: Throwable? = null
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.FAILED
|
||||
Toast.makeText(
|
||||
context,
|
||||
translation.format("update_download_failed_toast", "error" to errorMessage),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
|
||||
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun startHttpFallbackDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
filePath: String,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val partialFile = File("$filePath.part")
|
||||
val outputFile = File(filePath)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
|
||||
partialFile.parentFile?.mkdirs()
|
||||
if (partialFile.exists()) partialFile.delete()
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.header("User-Agent", "PurrfectSnap-Updater")
|
||||
.build()
|
||||
|
||||
fallbackHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("HTTP_${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
|
||||
val contentLength = body.contentLength()
|
||||
var downloadedBytes = 0L
|
||||
|
||||
body.byteStream().use { input ->
|
||||
partialFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!partialFile.renameTo(outputFile)) {
|
||||
partialFile.copyTo(outputFile, overwrite = true)
|
||||
partialFile.delete()
|
||||
}
|
||||
installDownloadedFile(remoteContext, outputFile, scope)
|
||||
}.onFailure {
|
||||
runCatching { partialFile.delete() }
|
||||
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(
|
||||
context: Context,
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
fileName: String,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val fetch = getInstance(context)
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
val fetch = getInstance(remoteContext)
|
||||
val filePath = File(context.externalCacheDir, fileName).path
|
||||
remoteContext.log.info("Starting update download from $downloadUrl -> $filePath", TAG)
|
||||
val request = Request(downloadUrl, filePath).apply {
|
||||
priority = Priority.HIGH
|
||||
networkType = NetworkType.ALL
|
||||
}
|
||||
listener?.let { fetch.removeListener(it) }
|
||||
var fallbackAttempted = false
|
||||
listener = object : AbstractFetchListener() {
|
||||
override fun onAdded(download: Download) {
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
remoteContext.log.info("Queued update download: ${download.file}", TAG)
|
||||
}
|
||||
|
||||
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
Toast.makeText(context, "Download started", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, translation["update_download_started_toast"], Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) {
|
||||
@@ -84,44 +246,21 @@ object UpdateDownloader {
|
||||
}
|
||||
|
||||
override fun onCompleted(download: Download) {
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
Toast.makeText(context, "Download completed", Toast.LENGTH_SHORT).show()
|
||||
runCatching {
|
||||
val downloadedFile = File(download.file)
|
||||
val unzipDir = File(context.externalCacheDir, "update")
|
||||
if (unzipDir.exists()) {
|
||||
unzipDir.deleteRecursively()
|
||||
}
|
||||
unzipDir.mkdirs()
|
||||
unzip(downloadedFile, unzipDir)
|
||||
val apkFile = unzipDir.walk().find { it.isFile && it.extension == "apk" }
|
||||
?: throw Exception("No APK found in the downloaded file")
|
||||
val uri = FileProvider.getUriForFile(context, "me.eternal.purrfectsnap.fileprovider", apkFile)
|
||||
val installIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(installIntent)
|
||||
}.onFailure {
|
||||
it.printStackTrace()
|
||||
Toast.makeText(context, "Failed to install update. Check logs for more details.", Toast.LENGTH_SHORT).show()
|
||||
downloadState.value = DownloadState.FAILED
|
||||
}
|
||||
installDownloadedFile(remoteContext, File(download.file), scope)
|
||||
fetch.removeListener(this)
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(download: Download, error: Error, throwable: Throwable?) {
|
||||
downloadState.value = DownloadState.FAILED
|
||||
Toast.makeText(context, "Download failed: $error", Toast.LENGTH_SHORT).show()
|
||||
fetch.removeListener(this)
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
|
||||
fallbackAttempted = true
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
downloadProgress.value = 0f
|
||||
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
|
||||
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
|
||||
return
|
||||
}
|
||||
failDownload(remoteContext, scope, error.toString(), throwable)
|
||||
}
|
||||
}
|
||||
fetch.addListener(listener!!)
|
||||
|
||||
@@ -8,13 +8,34 @@ import okhttp3.Request
|
||||
|
||||
|
||||
object Updater {
|
||||
enum class Channel { STABLE, PRERELEASE }
|
||||
|
||||
data class LatestRelease(
|
||||
val versionName: String,
|
||||
val releaseUrl: String,
|
||||
val workflowId: Long?,
|
||||
val assetDownloads: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
private fun fetchLatestRelease() = runCatching {
|
||||
private fun isVersionGreater(version: String, current: String): Boolean {
|
||||
fun segments(raw: String) = raw
|
||||
.split(Regex("[^0-9]+"))
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
val v = segments(version)
|
||||
val c = segments(current)
|
||||
val size = maxOf(v.size, c.size)
|
||||
for (i in 0 until size) {
|
||||
val vi = v.getOrElse(i) { 0 }
|
||||
val ci = c.getOrElse(i) { 0 }
|
||||
if (vi > ci) return true
|
||||
if (vi < ci) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun fetchLatestRelease(channel: Channel) = runCatching {
|
||||
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
|
||||
val response = OkHttpClient().newCall(endpoint).execute()
|
||||
|
||||
@@ -24,17 +45,45 @@ object Updater {
|
||||
if (it.size() == 0) throw Throwable("No releases found")
|
||||
}
|
||||
|
||||
val latestRelease = releases.get(0).asJsonObject
|
||||
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString
|
||||
if (latestVersion.removePrefix("v") == BuildConfig.VERSION_NAME) return@runCatching null
|
||||
val currentVersion = BuildConfig.VERSION_NAME
|
||||
val latestRelease = releases.mapNotNull { it.asJsonObject }.firstOrNull { release ->
|
||||
if (release.get("draft")?.asBoolean != false) return@firstOrNull false
|
||||
val matchesChannel = when (channel) {
|
||||
Channel.STABLE -> release.get("prerelease")?.asBoolean == false
|
||||
Channel.PRERELEASE -> release.get("prerelease")?.asBoolean == true
|
||||
}
|
||||
if (!matchesChannel) return@firstOrNull false
|
||||
val latestVersion = release.getAsJsonPrimitive("tag_name")?.asString?.removePrefix("v") ?: return@firstOrNull false
|
||||
isVersionGreater(latestVersion, currentVersion)
|
||||
} ?: throw Throwable("No matching releases found for $channel channel")
|
||||
|
||||
val latestVersion = latestRelease.getAsJsonPrimitive("tag_name").asString.removePrefix("v")
|
||||
if (latestVersion == BuildConfig.VERSION_NAME) return@runCatching null
|
||||
val assets = latestRelease.getAsJsonArray("assets")?.mapNotNull { element ->
|
||||
val obj = element.asJsonObject
|
||||
val name = obj.getAsJsonPrimitive("name")?.asString?.lowercase() ?: return@mapNotNull null
|
||||
val url = obj.getAsJsonPrimitive("browser_download_url")?.asString ?: return@mapNotNull null
|
||||
name to url
|
||||
} ?: emptyList()
|
||||
|
||||
val assetDownloads = buildMap<String, String> {
|
||||
assets.forEach { (name, url) ->
|
||||
when {
|
||||
name.contains("arm64") || name.contains("armv8") -> put("arm64", url)
|
||||
name.contains("armeabi") || name.contains("armv7") || name.contains("arm32") -> put("armv7", url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LatestRelease(
|
||||
versionName = latestVersion,
|
||||
releaseUrl = endpoint.url.toString().replace("api.", "").replace("repos/", ""),
|
||||
workflowId = null
|
||||
releaseUrl = latestRelease.getAsJsonPrimitive("html_url")?.asString
|
||||
?: endpoint.url.toString().replace("api.", "").replace("repos/", ""),
|
||||
workflowId = null,
|
||||
assetDownloads = assetDownloads
|
||||
)
|
||||
}.onFailure {
|
||||
AbstractLogger.directError("Failed to fetch latest release", it)
|
||||
AbstractLogger.directError("Failed to fetch latest release ($channel)", it)
|
||||
}.getOrNull()
|
||||
|
||||
private fun fetchLatestDebugCI() = runCatching {
|
||||
@@ -60,7 +109,15 @@ object Updater {
|
||||
AbstractLogger.directError("Failed to fetch latest debug CI", it)
|
||||
}.getOrNull()
|
||||
|
||||
val latestRelease by lazy {
|
||||
if (BuildConfig.DEBUG) fetchLatestDebugCI() else fetchLatestRelease()
|
||||
private val cache = mutableMapOf<Channel, LatestRelease?>()
|
||||
|
||||
fun getLatestRelease(channel: Channel): LatestRelease? {
|
||||
return cache.getOrPut(channel) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
|
||||
} else {
|
||||
fetchLatestRelease(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,19 +22,24 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -45,6 +52,7 @@ import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
@@ -58,44 +66,75 @@ class FileImportsRoot: Routes.Route() {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportFab(onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
val border = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
val fill = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.shadow(
|
||||
elevation = 12.dp,
|
||||
shape = shape,
|
||||
ambientColor = PurrfectPalette.glowSecondary.copy(alpha = 0.2f),
|
||||
spotColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
|
||||
)
|
||||
.clip(shape)
|
||||
.background(fill)
|
||||
.border(1.dp, border, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Upload, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = translation["import_file_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Row {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = {
|
||||
Icon(Icons.Default.Upload, contentDescription = null)
|
||||
},
|
||||
text = {
|
||||
Text(translation["import_file_button"])
|
||||
},
|
||||
onClick = {
|
||||
context.coroutineScope.launch {
|
||||
activityLauncherHelper.openFile { filePath ->
|
||||
val fileUri = Uri.parse(filePath)
|
||||
runCatching {
|
||||
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
|
||||
if (!file.exists()) {
|
||||
context.shortToast(translation["file_not_found"])
|
||||
return@openFile
|
||||
}
|
||||
context.fileHandleManager.importFile(file.name!!) {
|
||||
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
|
||||
inputStream.copyTo(this)
|
||||
}
|
||||
ImportFab {
|
||||
context.coroutineScope.launch {
|
||||
activityLauncherHelper.openFile { filePath ->
|
||||
val fileUri = Uri.parse(filePath)
|
||||
runCatching {
|
||||
DocumentFile.fromSingleUri(context.activity!!, fileUri)?.let { file ->
|
||||
if (!file.exists()) {
|
||||
context.shortToast(translation["file_not_found"])
|
||||
return@openFile
|
||||
}
|
||||
context.fileHandleManager.importFile(file.name!!) {
|
||||
context.androidContext.contentResolver.openInputStream(fileUri)?.use { inputStream ->
|
||||
inputStream.copyTo(this)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import file", it)
|
||||
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["file_imported"])
|
||||
coroutineScope.launch {
|
||||
reloadDispatcher.dispatch()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import file", it)
|
||||
context.shortToast(translation.format("file_import_failed", "error" to it.message.toString()))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["file_imported"])
|
||||
coroutineScope.launch {
|
||||
reloadDispatcher.dispatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,78 +142,25 @@ class FileImportsRoot: Routes.Route() {
|
||||
val files = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = reloadDispatcher) {
|
||||
context.fileHandleManager.getStoredFiles()
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val titleText = context.translation["manager.routes.file_imports"]
|
||||
var topBarHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = topBarHeight + 8.dp,
|
||||
bottom = routes.bottomPadding + 16.dp
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 18.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = PurrfectPalette.cardOverlayColor
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AttachFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["import_file_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["manager.dialogs.file_imports.settings_select_file_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 16.dp, top = 4.dp)
|
||||
) {
|
||||
item {
|
||||
if (files.isEmpty()) {
|
||||
Surface(
|
||||
@@ -279,7 +265,15 @@ class FileImportsRoot: Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = titleText ?: translation["import_file_button"],
|
||||
subtitle = null,
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier.onGloballyPositioned {
|
||||
topBarHeight = with(density) { it.size.height.toDp() }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,11 +385,12 @@ class LoggerHistoryRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
OutlinedTextField(
|
||||
value = stringFilter,
|
||||
onValueChange = { stringFilter = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",
|
||||
|
||||
@@ -143,7 +143,7 @@ class ManageReposSection: Routes.Route() {
|
||||
addRepo(url)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -29,6 +34,8 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
@@ -45,6 +52,7 @@ import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -128,10 +136,15 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val exportSensitiveData = it.arguments?.getBoolean("exportSensitiveData") ?: false
|
||||
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
|
||||
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"]
|
||||
val parser = remember { ConfigParser() }
|
||||
val savedLocations = remember {
|
||||
if (includeSavedLocations) context.database.getLocationCoordinates() else null
|
||||
}
|
||||
val featuresByCategory = remember {
|
||||
parser.parse(context.config.exportToString(exportSensitiveData))
|
||||
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
@@ -140,11 +153,15 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
@@ -163,52 +180,42 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["back_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
)
|
||||
)
|
||||
Button(
|
||||
onClick = { routes.navController.popBackStack() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
IconButton(
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
|
||||
context.config.writeConfig()
|
||||
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
|
||||
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
|
||||
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
|
||||
}
|
||||
}.onFailure {
|
||||
@@ -220,14 +227,19 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = translation["save_button"],
|
||||
tint = Color.White
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
}
|
||||
Text(exportLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -44,10 +49,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
|
||||
class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
|
||||
@@ -60,6 +69,44 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
val indentation: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports saved locations from JSON array into database with de-duplication.
|
||||
* Only adds locations that don't already exist (within coordinate tolerance).
|
||||
*/
|
||||
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
|
||||
val existingLocations = context.database.getLocationCoordinates()
|
||||
|
||||
for (i in 0 until locationsArray.size()) {
|
||||
val locationObj = locationsArray.get(i).asJsonObject
|
||||
val name = locationObj.get("name")?.asString ?: continue
|
||||
val latitude = locationObj.get("latitude")?.asDouble ?: continue
|
||||
val longitude = locationObj.get("longitude")?.asDouble ?: continue
|
||||
val radius = locationObj.get("radius")?.asDouble ?: 100.0
|
||||
|
||||
// Check for existing location with similar coordinates (de-duplication)
|
||||
val existingMatch = existingLocations.find { existing ->
|
||||
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
|
||||
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
|
||||
}
|
||||
|
||||
if (existingMatch == null) {
|
||||
// No duplicate found, add as new location
|
||||
val newLocation = LocationCoordinates().apply {
|
||||
this.name = name
|
||||
this.latitude = latitude
|
||||
this.longitude = longitude
|
||||
this.radius = radius
|
||||
}
|
||||
context.database.addOrUpdateLocationCoordinate(null, newLocation)
|
||||
}
|
||||
// If duplicate exists, skip (do not update or delete existing)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConfigParser {
|
||||
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
|
||||
val featureList = mutableListOf<ImportedFeature>()
|
||||
@@ -185,13 +232,18 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val importLabel = translation["confirm_button"]
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -213,40 +265,50 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
Button(
|
||||
onClick = { routes.navController.popBackStack() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["back_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
routes.configJsonForImport?.let { json ->
|
||||
runCatching {
|
||||
context.config.loadFromString(json)
|
||||
val savedLocationsJson = context.config.loadFromString(json)
|
||||
|
||||
// Import saved locations if present in the JSON
|
||||
savedLocationsJson?.let { locationsArray ->
|
||||
importSavedLocations(locationsArray)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
context.longToast(
|
||||
context.translation.format(
|
||||
"config_import_failure_toast",
|
||||
"error" to (err.message ?: "Unknown error")
|
||||
"error" to (err.message ?: context.translation["common.unknown_error"])
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -261,7 +323,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["confirm_button"])
|
||||
Text(importLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,118 +338,118 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(featuresByCategory.toList()) { (category, features) ->
|
||||
val isExpanded = expandedState[category] ?: false
|
||||
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
|
||||
items(featuresByCategory.toList()) { (category, features) ->
|
||||
val isExpanded = expandedState[category] ?: false
|
||||
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expandedState[category] = !isExpanded },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expandedState[category] = !isExpanded },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = category,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { expandedState[category] = !isExpanded }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = translation["expand_button_description"],
|
||||
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = category,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { expandedState[category] = !isExpanded }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = translation["expand_button_description"],
|
||||
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
features.forEachIndexed { index, feature ->
|
||||
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
|
||||
is List<*> -> {
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
features.forEachIndexed { index, feature ->
|
||||
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
|
||||
is List<*> -> {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
parsedValue.forEachIndexed { itemIndex, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NumberBubble(itemIndex + 1)
|
||||
Text(
|
||||
text = item.toString(),
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
parsedValue.forEachIndexed { itemIndex, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
NumberBubble(itemIndex + 1)
|
||||
Text(
|
||||
text = item.toString(),
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is String -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (feature.indentation * 16).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = parsedValue,
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
is String -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = (feature.indentation * 16).dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = feature.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = parsedValue,
|
||||
color = PurrfectPalette.glowSecondary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < features.size - 1) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < features.size - 1) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberBubble(number: Int) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = translation["clear_list_button"],
|
||||
text = translation["dialog_clear_confirmation_text"],
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
confirmButtonText = "Clear",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
confirmButtonText = translation["dialog_clear_confirm_button"],
|
||||
dismissButtonText = translation["dialog_clear_cancel_button"],
|
||||
onDismiss = { confirmationDialog = false },
|
||||
onConfirm = {
|
||||
context.database.clearRuleIds(currentRuleType.key)
|
||||
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
@@ -390,7 +391,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Clear")
|
||||
Text(text = translation["dialog_clear_confirm_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
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.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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
class HomeAbout : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
|
||||
}
|
||||
|
||||
key(themeId) {
|
||||
val currentTheme = ManagerTheme.fromId(themeId).theme
|
||||
with(currentTheme) {
|
||||
this@HomeAbout.AboutScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeveloperCard(
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.longValue > tapTimeoutMs) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
},
|
||||
modifier = modifier.scaleOnPress(tapSource),
|
||||
interactionSource = tapSource,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = imageRes),
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
PurrfectMarqueeText(
|
||||
text = name,
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
|
||||
import me.eternal.purrfectsnap.common.logger.LogChannel
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
|
||||
class HomeLogs : Routes.Route() {
|
||||
private val logListState by lazy { LazyListState(0) }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val externalRefreshTick = mutableStateOf(0)
|
||||
internal val logListState = LazyListState()
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val externalRefreshTick = mutableIntStateOf(0)
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
private fun clearLogsAndReload() {
|
||||
internal fun clearLogsAndReload() {
|
||||
context.coroutineScope.launch {
|
||||
context.log.clearLogs()
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogs() {
|
||||
internal fun exportLogs() {
|
||||
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
|
||||
context.coroutineScope.launch {
|
||||
context.shortToast(translation["saving_logs_toast"])
|
||||
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<LogLine>() }
|
||||
val mainExecutor = remember { context.androidContext.mainExecutor }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
fun refreshLogs() {
|
||||
coroutineScope.launch {
|
||||
val readerResult = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
mainExecutor.execute {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast("Failed to read logs!")
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
val filteredLogs = withContext(Dispatchers.IO) {
|
||||
(0 until reader.lineCount).mapNotNull { index ->
|
||||
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
|
||||
}
|
||||
}
|
||||
visibleLogs.clear()
|
||||
visibleLogs.addAll(filteredLogs)
|
||||
}
|
||||
delay(220)
|
||||
if (visibleLogs.isNotEmpty()) {
|
||||
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
|
||||
logListState.scrollToItem(targetIndex)
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
}
|
||||
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LogsFloatingBar(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
onExport = { exportLogs() },
|
||||
onClear = { clearLogsAndReload() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (visibleLogs.isEmpty() && logReader != null) {
|
||||
EmptyLogsState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = logListState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = routes.bottomPadding + 22.dp
|
||||
)
|
||||
) {
|
||||
items(visibleLogs, key = { it.hashCode() }) { line ->
|
||||
LogEntryCard(line = line, composeContext = composeContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp)
|
||||
)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
|
||||
}
|
||||
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(
|
||||
@@ -226,6 +122,12 @@ class HomeLogs : Routes.Route() {
|
||||
) {
|
||||
val firstVisibleItem by remember { derivedStateOf { logListState.firstVisibleItemIndex } }
|
||||
val layoutInfo by remember { derivedStateOf { logListState.layoutInfo } }
|
||||
val floatingButtonColors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = PurrfectPalette.cardOverlayColor,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.35f)
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
@@ -243,7 +145,8 @@ class HomeLogs : Routes.Route() {
|
||||
logListState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
enabled = firstVisibleItem != 0
|
||||
enabled = firstVisibleItem != 0,
|
||||
colors = floatingButtonColors
|
||||
) {
|
||||
Icon(Icons.Filled.KeyboardDoubleArrowUp, contentDescription = null)
|
||||
}
|
||||
@@ -253,7 +156,8 @@ class HomeLogs : Routes.Route() {
|
||||
logListState.scrollToItem((logListState.layoutInfo.totalItemsCount - 1).takeIf { it >= 0 } ?: return@launch)
|
||||
}
|
||||
},
|
||||
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1
|
||||
enabled = layoutInfo.visibleItemsInfo.lastOrNull()?.index != layoutInfo.totalItemsCount - 1,
|
||||
colors = floatingButtonColors
|
||||
) {
|
||||
Icon(Icons.Filled.KeyboardDoubleArrowDown, contentDescription = null)
|
||||
}
|
||||
@@ -263,20 +167,19 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogsFloatingBar(
|
||||
internal fun LogsFloatingBar(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onExport: () -> Unit,
|
||||
onClear: () -> Unit
|
||||
) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = topPadding),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
@@ -284,87 +187,93 @@ class HomeLogs : Routes.Route() {
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
Text(
|
||||
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = translation["common.back"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = PurrfectPalette.glowSecondary
|
||||
Text(
|
||||
text = translation["manager.routes.home_logs"] ?: "Logs",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = translation["refresh_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showDropDown = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClear()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
|
||||
text = { Text(translation["clear_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowPrimary
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onExport()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
text = { Text(translation["export_logs_button"], color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowSecondary
|
||||
},
|
||||
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onExport()
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFFF9CAB)
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
|
||||
onClick = {
|
||||
onClear()
|
||||
showMenu = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +282,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLogsState() {
|
||||
internal fun EmptyLogsState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -404,7 +313,7 @@ class HomeLogs : Routes.Route() {
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Pull to refresh or trigger an action to see new entries.",
|
||||
text = translation["refresh_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -414,8 +323,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
|
||||
// Normalize overly fragmented log text (some entries were rendered with one character per line)
|
||||
internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
|
||||
val normalizedMessage = remember(line.message) {
|
||||
val cleaned = line.message.replace("\r", "")
|
||||
val fragments = cleaned.lines()
|
||||
@@ -526,14 +434,14 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
|
||||
internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
|
||||
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
|
||||
LogLevel.WARN -> Color(0xFFFFD782)
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
|
||||
}
|
||||
|
||||
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
|
||||
internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
|
||||
LogLevel.DEBUG -> "Debug"
|
||||
LogLevel.INFO -> "Info"
|
||||
LogLevel.VERBOSE -> "Verbose"
|
||||
@@ -542,14 +450,14 @@ class HomeLogs : Routes.Route() {
|
||||
LogLevel.ASSERT -> "Assert"
|
||||
}
|
||||
|
||||
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
|
||||
internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
|
||||
LogLevel.DEBUG -> Icons.Outlined.BugReport
|
||||
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
|
||||
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
|
||||
LogLevel.WARN -> Icons.Outlined.Warning
|
||||
}
|
||||
|
||||
private fun shouldHideLog(line: LogLine): Boolean {
|
||||
internal fun shouldHideLog(line: LogLine): Boolean {
|
||||
val message = line.message.lowercase()
|
||||
val tag = line.tag.lowercase()
|
||||
return message.startsWith("blocked ep") ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import android.content.SharedPreferences
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -15,11 +16,14 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -30,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -41,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
@@ -49,6 +53,8 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.task.UpdateCheckWorker
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -63,13 +69,33 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
class HomeSettings : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val dialogs by lazy { AlertDialogs(context.translation) }
|
||||
|
||||
private fun scheduleUpdateCheck() {
|
||||
internal fun scheduleUpdateCheck() {
|
||||
val workManager = WorkManager.getInstance(context.androidContext)
|
||||
if (context.config.root.global.updateSettings.autoUpdateCheck.get()) {
|
||||
val frequency = context.config.root.global.updateSettings.updateCheckFrequency.get()
|
||||
val updateSettings = context.config.root.global.updateSettings
|
||||
var configDirty = false
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.autoUpdateCheck.set(true)
|
||||
true
|
||||
}
|
||||
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateCheckFrequency.set("daily")
|
||||
"daily"
|
||||
}
|
||||
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
|
||||
configDirty = true
|
||||
updateSettings.updateChannel.set("stable")
|
||||
"stable"
|
||||
}
|
||||
if (configDirty) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
|
||||
if (autoUpdateCheck) {
|
||||
val repeatInterval = when (frequency) {
|
||||
"daily" -> 1L
|
||||
"weekly" -> 7L
|
||||
@@ -86,6 +112,7 @@ class HomeSettings : Routes.Route() {
|
||||
.putString("channel_description", translation["update_notification_channel_description"])
|
||||
.putString("notification_title", translation["update_notification_title"])
|
||||
.putString("notification_text", translation["update_notification_text"])
|
||||
.putString("update_channel", updateChannel)
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
|
||||
@@ -102,11 +129,31 @@ class HomeSettings : Routes.Route() {
|
||||
workManager.cancelUniqueWork("purrfectsnap_update_check")
|
||||
}
|
||||
}
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
key(themeId) {
|
||||
val currentTheme = ManagerTheme.fromId(themeId).theme
|
||||
with(currentTheme) {
|
||||
this@HomeSettings.SettingsScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowTitle(title: String) {
|
||||
internal fun RowTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
@@ -115,16 +162,22 @@ class HomeSettings : Routes.Route() {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PremiumPreferenceToggle(
|
||||
internal fun PremiumPreferenceToggle(
|
||||
sharedPreferences: SharedPreferences,
|
||||
key: String,
|
||||
text: String,
|
||||
defaultValue: Boolean = false
|
||||
defaultValue: Boolean = false,
|
||||
confirmDisableTitle: String? = null,
|
||||
confirmDisableText: String? = null
|
||||
) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
|
||||
var showDisableDialog by remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
|
||||
LaunchedEffect(realKey) {
|
||||
if (!sharedPreferences.contains(realKey)) {
|
||||
@@ -133,6 +186,24 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDisableDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDisableDialog = false },
|
||||
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
|
||||
text = confirmDisableText.orEmpty(),
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = positiveLabel,
|
||||
dismissButtonText = negativeLabel,
|
||||
onConfirm = {
|
||||
value = false
|
||||
sharedPreferences.edit().putBoolean(realKey, false).apply()
|
||||
showDisableDialog = false
|
||||
},
|
||||
onDismiss = { showDisableDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -141,11 +212,15 @@ class HomeSettings : Routes.Route() {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
value = !value
|
||||
sharedPreferences
|
||||
.edit() {
|
||||
val nextValue = !value
|
||||
if (!nextValue && confirmDisableTitle != null) {
|
||||
showDisableDialog = true
|
||||
} else {
|
||||
value = nextValue
|
||||
sharedPreferences.edit() {
|
||||
putBoolean(realKey, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -161,7 +236,7 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
|
||||
internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
@@ -193,7 +268,7 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
|
||||
internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
|
||||
var confirmationDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -241,8 +316,9 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShiftedRow(
|
||||
internal fun ShiftedRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.Top,
|
||||
@@ -255,501 +331,52 @@ class HomeSettings : Routes.Route() {
|
||||
) { content(this) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val contextC = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
@Composable
|
||||
internal fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["manager.routes.home_settings"],
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction ->
|
||||
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
|
||||
}
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["haptic_feedback_label"])
|
||||
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = hapticFeedbackEnabled,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
hapticFeedbackEnabled = it
|
||||
context.config.root.global.uiSettings.hapticFeedback.set(it)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedFrequency by remember { mutableStateOf(context.config.root.global.updateSettings.updateCheckFrequency.getNullable() ?: "weekly") }
|
||||
var frequencyMenuExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["auto_update_check"])
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = autoUpdateCheck,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
autoUpdateCheck = it
|
||||
if (it && context.config.root.global.updateSettings.updateCheckFrequency.getNullable() == null) {
|
||||
selectedFrequency = "weekly"
|
||||
context.config.root.global.updateSettings.updateCheckFrequency.set("weekly")
|
||||
}
|
||||
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = frequencyMenuExpanded,
|
||||
onExpandedChange = { frequencyMenuExpanded = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 26.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = translation.getOrNull("update_check_frequency_${selectedFrequency}") ?: selectedFrequency,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyMenuExpanded) },
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = frequencyMenuExpanded,
|
||||
onDismissRequest = { frequencyMenuExpanded = false }
|
||||
) {
|
||||
listOf("daily", "weekly", "monthly").forEach { frequency ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = translation.getOrNull("update_check_frequency_${frequency}") ?: frequency) },
|
||||
onClick = {
|
||||
selectedFrequency = frequency
|
||||
frequencyMenuExpanded = false
|
||||
context.config.root.global.updateSettings.updateCheckFrequency.set(frequency)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredMessageCount()
|
||||
}
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredStoriesCount()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val summary = translation.format(
|
||||
"message_logger_summary",
|
||||
"messageCount" to storedMessagesCount.toString(),
|
||||
"storyCount" to storedStoriesCount.toString()
|
||||
).replace("\n", " | ")
|
||||
Text(
|
||||
summary,
|
||||
maxLines = 2,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
||||
context.messageLogger.databaseFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to export database", it)
|
||||
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["export_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db")
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
routes.viewLoggerHistory.navigate {
|
||||
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file", it)
|
||||
context.longToast("Failed to open file! ${it.localizedMessage}")
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["view_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.messageLogger.purgeAll()
|
||||
storedMessagesCount = 0
|
||||
storedStoriesCount = 0
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear messages", it)
|
||||
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["clear_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
onClick = { routes.loggerHistory.navigate() },
|
||||
colors = sharedOutlinedColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Text(translation["view_logger_history_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = translation["friend_notes_description"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
val notes = context.database.getAllScopeNotes()
|
||||
if (notes.isEmpty()) {
|
||||
context.shortToast(translation["friend_notes_no_notes_to_backup"])
|
||||
return@runCatching
|
||||
}
|
||||
val json = context.gson.toJson(notes)
|
||||
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
|
||||
it.write(json.toByteArray())
|
||||
}
|
||||
context.shortToast(translation["friend_notes_backup_success"])
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to backup notes", it)
|
||||
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["backup_button"])
|
||||
}
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/json") { uri ->
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
|
||||
val json = it.reader().readText()
|
||||
val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
|
||||
context.database.setAllScopeNotes(notes)
|
||||
context.shortToast(translation["friend_notes_restore_success"])
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to restore notes", it)
|
||||
context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["restore_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 26.dp)
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextField(
|
||||
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
colors = ExposedDropdownMenuDefaults.textFieldColors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType ->
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
selectedFileType = fileType
|
||||
}, text = {
|
||||
Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.coroutineScope.launch {
|
||||
selectedFileType.resolve(context.androidContext).delete()
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear file", it)
|
||||
context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.1f),
|
||||
contentColor = Color.White
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
PremiumPreferenceToggle(
|
||||
context.sharedPreferences,
|
||||
key = "test_mode",
|
||||
text = translation["test_mode_label"],
|
||||
defaultValue = true
|
||||
)
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AestheticDropdownField(
|
||||
value: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = value, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.home
|
||||
|
||||
import android.view.MotionEvent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
class RetroGameScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.retro_flight") }
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val gridWidth = 120
|
||||
val gridHeight = 160
|
||||
val shipSpeed = 1.7f
|
||||
val shipRadius = 4.5f
|
||||
val maxSpeedMultiplier = 2.2f
|
||||
val speedRampPerScore = 0.035f
|
||||
val rng = remember { Random(System.currentTimeMillis()) }
|
||||
val pixelFont = remember { FontFamily.Monospace }
|
||||
|
||||
data class Star(var x: Float, var y: Float, var speed: Float)
|
||||
data class Planet(var x: Float, var y: Float, var radius: Float, var speed: Float)
|
||||
|
||||
var score by remember { mutableIntStateOf(0) }
|
||||
var isGameOver by remember { mutableStateOf(false) }
|
||||
var leftPressed by remember { mutableStateOf(false) }
|
||||
var rightPressed by remember { mutableStateOf(false) }
|
||||
var shipX by remember { mutableFloatStateOf(gridWidth / 2f) }
|
||||
val shipY = gridHeight - 20f
|
||||
|
||||
val stars = remember { mutableListOf<Star>() }
|
||||
val planets = remember { mutableListOf<Planet>() }
|
||||
|
||||
val frameBitmap = remember { ImageBitmap(gridWidth, gridHeight) }
|
||||
val frameCanvas = remember { Canvas(frameBitmap) }
|
||||
val paint = remember { Paint() }
|
||||
var frameTick by remember { mutableIntStateOf(0) }
|
||||
|
||||
fun spawnPlanet(): Planet {
|
||||
val radius = rng.nextInt(6, 14).toFloat()
|
||||
val x = rng.nextInt(radius.roundToInt(), gridWidth - radius.roundToInt()).toFloat()
|
||||
val y = rng.nextInt(-gridHeight, -20).toFloat()
|
||||
val speed = rng.nextInt(10, 20) / 10f
|
||||
return Planet(x = x, y = y, radius = radius, speed = speed)
|
||||
}
|
||||
|
||||
fun resetGame() {
|
||||
score = 0
|
||||
isGameOver = false
|
||||
shipX = gridWidth / 2f
|
||||
stars.clear()
|
||||
planets.clear()
|
||||
repeat(28) {
|
||||
stars.add(
|
||||
Star(
|
||||
x = rng.nextInt(0, gridWidth).toFloat(),
|
||||
y = rng.nextInt(0, gridHeight).toFloat(),
|
||||
speed = rng.nextInt(6, 12) / 10f
|
||||
)
|
||||
)
|
||||
}
|
||||
repeat(4) { planets.add(spawnPlanet()) }
|
||||
}
|
||||
|
||||
fun drawRect(left: Float, top: Float, right: Float, bottom: Float, color: Color) {
|
||||
paint.color = color
|
||||
paint.alpha = 1f
|
||||
frameCanvas.drawRect(Rect(left, top, right, bottom), paint)
|
||||
}
|
||||
|
||||
fun drawCircle(centerX: Float, centerY: Float, radius: Float, color: Color) {
|
||||
paint.color = color
|
||||
frameCanvas.drawCircle(androidx.compose.ui.geometry.Offset(centerX, centerY), radius, paint)
|
||||
}
|
||||
|
||||
fun drawShip() {
|
||||
val sx = shipX.roundToInt().toFloat()
|
||||
drawRect(sx - 2f, shipY, sx + 3f, shipY + 1f, Color(0xFFE7F6FF))
|
||||
drawRect(sx - 3f, shipY + 1f, sx + 4f, shipY + 3f, Color(0xFFE7F6FF))
|
||||
drawRect(sx - 4f, shipY + 3f, sx + 5f, shipY + 6f, Color(0xFFE7F6FF))
|
||||
drawRect(sx - 2f, shipY + 6f, sx + 3f, shipY + 8f, Color(0xFFE7F6FF))
|
||||
drawRect(sx - 1f, shipY + 2f, sx + 2f, shipY + 4f, Color(0xFF8AF4FF))
|
||||
if (!isGameOver) {
|
||||
drawRect(sx - 1f, shipY + 8f, sx + 2f, shipY + 10f, Color(0xFFFFB24D))
|
||||
drawRect(sx, shipY + 10f, sx + 1f, shipY + 12f, Color(0xFFFF6B6B))
|
||||
}
|
||||
}
|
||||
|
||||
fun renderFrame() {
|
||||
drawRect(0f, 0f, gridWidth.toFloat(), gridHeight.toFloat(), Color(0xFF1B1440))
|
||||
drawRect(0f, 0f, gridWidth.toFloat(), 14f, Color(0xFF5236B8))
|
||||
stars.forEach { star ->
|
||||
drawRect(star.x, star.y, star.x + 1f, star.y + 1f, Color(0xFFEEE9FF))
|
||||
}
|
||||
planets.forEach { planet ->
|
||||
drawCircle(planet.x, planet.y, planet.radius, Color(0xFF4FD4FF))
|
||||
drawCircle(planet.x - planet.radius * 0.3f, planet.y - planet.radius * 0.2f, planet.radius * 0.35f, Color(0xFFB0F0FF))
|
||||
}
|
||||
drawShip()
|
||||
if (isGameOver) {
|
||||
drawRect(0f, gridHeight / 2f - 10f, gridWidth.toFloat(), gridHeight / 2f + 8f, Color(0xAA000000))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
resetGame()
|
||||
while (true) {
|
||||
delay(16)
|
||||
if (!isGameOver) {
|
||||
val speedMultiplier = min(
|
||||
maxSpeedMultiplier,
|
||||
1f + (score * speedRampPerScore)
|
||||
)
|
||||
val direction = (if (leftPressed) -1f else 0f) + (if (rightPressed) 1f else 0f)
|
||||
shipX = (shipX + direction * shipSpeed).coerceIn(8f, gridWidth - 8f)
|
||||
stars.forEach { star ->
|
||||
star.y += star.speed * speedMultiplier
|
||||
if (star.y > gridHeight) {
|
||||
star.y = 0f
|
||||
star.x = rng.nextInt(0, gridWidth).toFloat()
|
||||
}
|
||||
}
|
||||
planets.forEachIndexed { index, planet ->
|
||||
planet.y += planet.speed * speedMultiplier
|
||||
if (planet.y - planet.radius > gridHeight) {
|
||||
planets[index] = spawnPlanet()
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
planets.forEach { planet ->
|
||||
val dx = abs(planet.x - shipX)
|
||||
val dy = abs(planet.y - shipY)
|
||||
if (dx * dx + dy * dy < (planet.radius + shipRadius) * (planet.radius + shipRadius)) {
|
||||
isGameOver = true
|
||||
}
|
||||
}
|
||||
}
|
||||
renderFrame()
|
||||
frameTick++
|
||||
}
|
||||
}
|
||||
|
||||
val bottomPadding = routes.bottomPadding +
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isAphelion) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["title"] ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true)
|
||||
.aspectRatio(3f / 4f, matchHeightConstraintsFirst = true)
|
||||
.border(2.dp, Color.White.copy(alpha = 0.7f), RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF21195A), RoundedCornerShape(14.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
key(frameTick) {
|
||||
Image(
|
||||
bitmap = frameBitmap,
|
||||
contentDescription = null,
|
||||
filterQuality = FilterQuality.None,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.border(1.dp, Color.White.copy(alpha = 0.1f))
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = score.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = pixelFont,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 6.dp)
|
||||
)
|
||||
if (isGameOver) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = translation["game_over_label"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = pixelFont
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Button(
|
||||
onClick = { resetGame() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF5A43D6),
|
||||
contentColor = Color.White
|
||||
),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(56.dp)
|
||||
.pointerInteropFilter {
|
||||
when (it.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
leftPressed = true
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
leftPressed = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color(0xFF2E2568),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = translation["left_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(56.dp)
|
||||
.pointerInteropFilter {
|
||||
when (it.action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
rightPressed = true
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
rightPressed = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color(0xFF2E2568),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = translation["right_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
|
||||
|
||||
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
|
||||
|
||||
alertDialogs.DefaultDialogCard {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
val fieldColors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary,
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) }
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.94f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
|
||||
Text(
|
||||
text = translation["save_coordinates_dialog_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -127,7 +126,11 @@ class BetterLocationRoot : Routes.Route() {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
|
||||
text = context.translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to friendLocation.latitude.toFloat().toString(),
|
||||
"longitude" to friendLocation.longitude.toFloat().toString()
|
||||
),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
@@ -217,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemedEditLocationButton(onClick: () -> Unit) {
|
||||
private fun CoordinateActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
accent: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(42.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
|
||||
),
|
||||
onClick = onClick
|
||||
containerColor = accent.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
|
||||
Icon(icon, contentDescription = description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +242,8 @@ class BetterLocationRoot : Routes.Route() {
|
||||
val coordinatesProperty = remember {
|
||||
context.config.root.global.betterLocation.getPropertyPair("coordinates")
|
||||
}
|
||||
val providerProperty = remember { context.config.root.global.betterLocation.getPropertyPair("location_search_provider") }
|
||||
val apiKeyProperty = remember { context.config.root.global.betterLocation.getPropertyPair("google_maps_api_key") }
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val savedCoordinates = rememberAsyncMutableStateList(
|
||||
@@ -245,10 +255,17 @@ class BetterLocationRoot : Routes.Route() {
|
||||
var showMap by remember { mutableStateOf(false) }
|
||||
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
|
||||
var showTeleportDialog by remember { mutableStateOf(false) }
|
||||
var showProviderDialog by remember { mutableStateOf(false) }
|
||||
var showApiKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val marker = remember { mutableStateOf<Marker?>(null) }
|
||||
val mapView = remember { mutableStateOf<MapView?>(null) }
|
||||
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
|
||||
var spoofedCoordinates by remember(showTeleportDialog, showMap) {
|
||||
mutableStateOf(
|
||||
(coordinatesProperty.value.getNullable() as? Pair<*, *>)
|
||||
?: (0.0 to 0.0)
|
||||
)
|
||||
}
|
||||
|
||||
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
|
||||
context.coroutineScope.launch {
|
||||
@@ -271,6 +288,36 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
var currentProvider by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm")
|
||||
}
|
||||
var currentApiKey by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "")
|
||||
}
|
||||
|
||||
if (showProviderDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showProviderDialog = false
|
||||
context.config.writeConfig()
|
||||
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
|
||||
}) {
|
||||
alertDialogs.UniqueSelectionDialog(providerProperty)
|
||||
}
|
||||
}
|
||||
if (showApiKeyDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}) {
|
||||
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -337,9 +384,16 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
|
||||
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}) {
|
||||
alertDialogs.ChooseLocationDialog(
|
||||
property = coordinatesProperty,
|
||||
marker = marker,
|
||||
mapView = mapView,
|
||||
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
|
||||
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
|
||||
saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}
|
||||
) {
|
||||
showMap = false
|
||||
context.config.writeConfig()
|
||||
}
|
||||
@@ -395,6 +449,42 @@ class BetterLocationRoot : Routes.Route() {
|
||||
) {
|
||||
context.config.root.global.betterLocation.suspendLocationUpdates.set(it)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigSelector(text: String, value: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = value,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigInput(text: String, value: String, onClick: () -> Unit) {
|
||||
ConfigSelector(text, if (value.isNotEmpty()) "********" else translation["options.empty"], onClick)
|
||||
}
|
||||
|
||||
ConfigSelector(
|
||||
text = translation["location_search_provider_title"],
|
||||
value = translation["option_$currentProvider"]
|
||||
) { showProviderDialog = true }
|
||||
|
||||
if (currentProvider == "google_maps") {
|
||||
ConfigInput(
|
||||
text = translation["google_maps_api_key_title"],
|
||||
value = currentApiKey
|
||||
) { showApiKeyDialog = true }
|
||||
}
|
||||
}
|
||||
item {
|
||||
GlassPanel(
|
||||
@@ -588,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.Edit,
|
||||
description = translation["edit_icon_description"],
|
||||
accent = PurrfectPalette.glowPrimary
|
||||
) {
|
||||
showEditDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
description = translation["delete_icon_description"],
|
||||
accent = PurrfectPalette.glowSecondary
|
||||
) {
|
||||
showDeleteDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -109,7 +110,14 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -247,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -284,7 +292,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,8 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
Button(
|
||||
onClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md"
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -205,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
) {
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
|
||||
Box(
|
||||
@@ -310,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("version", "version" to (entry.version ?: "N/A")),
|
||||
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
@@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.isScriptEnabled
|
||||
import me.eternal.purrfectsnap.storage.setScriptEnabled
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.chooseFolder
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
|
||||
|
||||
class ScriptingRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.scripting") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
private var selectedTab by mutableStateOf(0)
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
internal var selectedTab by mutableStateOf(0)
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
return try {
|
||||
val installedScripts = context.scriptManager.getSyncedModules()
|
||||
installedScripts.any { module ->
|
||||
@@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
context.coroutineScope.launch {
|
||||
if (isScriptInstalledByUrl(scriptUrl)) {
|
||||
context.shortToast(translation["script_already_installed"])
|
||||
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportRemoteScript(
|
||||
internal fun ImportRemoteScript(
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
@@ -140,7 +142,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
return@launch
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import script", it)
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleActions(
|
||||
internal fun ModuleActions(
|
||||
script: ModuleInfo,
|
||||
canUpdate: Boolean,
|
||||
dismiss: () -> Unit
|
||||
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModuleItem(script: ModuleInfo) {
|
||||
internal fun ModuleItem(script: ModuleInfo) {
|
||||
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
|
||||
context.database.isScriptEnabled(script.name)
|
||||
}
|
||||
@@ -417,7 +419,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
)
|
||||
}
|
||||
if (openSettings) {
|
||||
Divider(color = Color.White.copy(alpha = 0.08f))
|
||||
HorizontalDivider(color = Color.White.copy(alpha = 0.08f))
|
||||
ScriptSettings(script)
|
||||
}
|
||||
}
|
||||
@@ -430,8 +432,8 @@ class ScriptingRootSection : Routes.Route() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {}
|
||||
|
||||
@Composable
|
||||
private fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
|
||||
internal fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation["select_folder_button"]
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -439,13 +441,13 @@ class ScriptingRootSection : Routes.Route() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(68.dp),
|
||||
modifier = Modifier.size(78.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
shadowElevation = 14.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
1.5.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
|
||||
@@ -456,17 +458,18 @@ class ScriptingRootSection : Routes.Route() {
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.size(66.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
|
||||
Color.Transparent
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -474,7 +477,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -482,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScriptSettings(script: ModuleInfo) {
|
||||
internal fun ScriptSettings(script: ModuleInfo) {
|
||||
val settingsInterface = remember {
|
||||
val module =
|
||||
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
||||
@@ -499,70 +502,23 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) showToast = true else scriptingFolder?.let { context.androidContext.openLink(it.uri.toString()) }
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = { context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs") },
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@ScriptingRootSection.ScriptingScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledTabContent(
|
||||
internal fun InstalledTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val scriptModules by rememberAsyncMutableState(
|
||||
@@ -613,7 +569,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
.heightIn(min = 260.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
@@ -729,7 +685,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
title = context.translation["manager.dialogs.scripting_warning.title"],
|
||||
text = context.translation["manager.dialogs.scripting_warning.content"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["button.ok"] ?: "OK",
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { if (timeout == 0) scriptingWarning = false },
|
||||
loading = timeout > 0,
|
||||
showCloseButton = false,
|
||||
@@ -785,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTabContent(
|
||||
internal fun CatalogTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -818,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingHeader(
|
||||
internal fun ScriptingHeader(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -861,7 +817,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.scripts"] ?: "Scripts",
|
||||
text = translation["manager.routes.scripts"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -896,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingTabSwitcher(
|
||||
internal fun ScriptingTabSwitcher(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -943,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
@@ -45,7 +44,7 @@ class AddFriendDialog(
|
||||
val getGroupState: (group: MessagingGroupInfo) -> Boolean,
|
||||
)
|
||||
|
||||
private val stateCache = mutableMapOf<String, Boolean>()
|
||||
private val stateCache = mutableStateMapOf<String, Boolean>()
|
||||
private val translation by lazy { context.translation.getCategory("manager.dialogs.add_friend")}
|
||||
|
||||
@Composable
|
||||
@@ -57,9 +56,13 @@ class AddFriendDialog(
|
||||
getCurrentState: () -> Boolean,
|
||||
onState: (Boolean) -> Unit = {},
|
||||
) {
|
||||
var currentState by rememberAsyncMutableState(defaultValue = stateCache[id] ?: false) {
|
||||
getCurrentState().also { stateCache[id] = it }
|
||||
val cachedState = stateCache[id]
|
||||
LaunchedEffect(id, cachedState) {
|
||||
if (cachedState == null) {
|
||||
stateCache[id] = getCurrentState()
|
||||
}
|
||||
}
|
||||
val currentState = stateCache[id] ?: false
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val cardShape = RoundedCornerShape(18.dp)
|
||||
@@ -68,9 +71,9 @@ class AddFriendDialog(
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.clickable {
|
||||
currentState = !currentState
|
||||
stateCache[id] = currentState
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
|
||||
val nextState = !currentState
|
||||
stateCache[id] = nextState
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(nextState) }
|
||||
},
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
@@ -125,9 +128,8 @@ class AddFriendDialog(
|
||||
Switch(
|
||||
checked = currentState,
|
||||
onCheckedChange = {
|
||||
currentState = it
|
||||
stateCache[id] = currentState
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(currentState) }
|
||||
stateCache[id] = it
|
||||
coroutineScope.launch(Dispatchers.IO) { onState(it) }
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
@@ -156,19 +158,12 @@ class AddFriendDialog(
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 18.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = translation["title"],
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["search_hint"],
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["title"],
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -185,10 +180,11 @@ class AddFriendDialog(
|
||||
value = searchKeyword.value,
|
||||
onValueChange = { searchKeyword.value = it },
|
||||
placeholder = {
|
||||
Text(text = translation["search_hint"])
|
||||
Text(text = translation["search_hint"], color = PurrfectPalette.textSecondary)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = translation["search_icon_description"])
|
||||
@@ -198,8 +194,15 @@ class AddFriendDialog(
|
||||
unfocusedContainerColor = PurrfectPalette.cardOverlayColor.copy(alpha = 0.8f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
cursorColor = Color.White
|
||||
)
|
||||
cursorColor = Color.White,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedLeadingIconColor = Color.White,
|
||||
unfocusedLeadingIconColor = Color.White.copy(alpha = 0.85f),
|
||||
focusedPlaceholderColor = PurrfectPalette.textSecondary,
|
||||
unfocusedPlaceholderColor = PurrfectPalette.textSecondary
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,19 +219,26 @@ class AddFriendDialog(
|
||||
var hasFetchError by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.database.receiveMessagingDataCallback = { friends, groups ->
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else friends
|
||||
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
|
||||
coroutineScope.launch {
|
||||
cachedFriends = friends.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else friends
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
}
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
}
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
if (context.bridgeService != null) {
|
||||
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
|
||||
} else {
|
||||
context.database.receiveMessagingDataCallback = updateSnapshot
|
||||
}
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
|
||||
runCatching {
|
||||
@@ -245,6 +255,13 @@ class AddFriendDialog(
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = {
|
||||
@@ -302,6 +319,15 @@ class AddFriendDialog(
|
||||
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
|
||||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
|
||||
} ?: cachedFriends!!
|
||||
val selectedFriendCount by remember(filteredFriends) {
|
||||
derivedStateOf {
|
||||
filteredFriends.count { friend ->
|
||||
stateCache[friend.userId] ?: actionHandler.getFriendState(friend)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hasFriendsSelected = selectedFriendCount > 0
|
||||
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
|
||||
|
||||
DialogHeader(searchKeyword)
|
||||
|
||||
@@ -337,14 +363,62 @@ class AddFriendDialog(
|
||||
|
||||
item {
|
||||
if (filteredFriends.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_friends"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 14.dp),
|
||||
color = Color.White
|
||||
)
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["category_friends"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredFriends.forEach { friend ->
|
||||
stateCache[friend.userId] = true
|
||||
actionHandler.onFriendState(friend, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !allFriendsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
PurrfectPalette.glowSecondary
|
||||
}
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredFriends.forEach { friend ->
|
||||
stateCache[friend.userId] = false
|
||||
actionHandler.onFriendState(friend, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasFriendsSelected
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasFriendsSelected) {
|
||||
PurrfectPalette.glowPrimary
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ class LoggedStories : Routes.Route() {
|
||||
content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use {
|
||||
it.data.toFile().absolutePath
|
||||
} ?: run {
|
||||
context.shortToast("Failed to get file")
|
||||
context.shortToast(translation["failed_to_get_file"])
|
||||
return@Button
|
||||
},
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
|
||||
@@ -4,18 +4,23 @@ import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.rounded.DeleteForever
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -46,6 +51,7 @@ import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -72,7 +78,7 @@ class ManageScope: Routes.Route() {
|
||||
var deleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf(id, scope)) {
|
||||
val titleText by rememberAsyncMutableState<String?>(null, keys = arrayOf<Any>(id, scope)) {
|
||||
when (scope) {
|
||||
SocialScope.FRIEND -> context.database.getFriendInfo(id)?.displayName
|
||||
SocialScope.GROUP -> context.database.getGroupInfo(id)?.name
|
||||
@@ -85,7 +91,7 @@ class ManageScope: Routes.Route() {
|
||||
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
|
||||
text = "",
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
confirmButtonText = translation["delete_button"] ?: "Delete",
|
||||
confirmButtonText = translation["delete_button"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
@@ -103,7 +109,7 @@ class ManageScope: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: "Manage",
|
||||
title = titleText ?: translation["manage_scope_title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { deleteConfirmDialog = true }) {
|
||||
@@ -304,6 +310,61 @@ class ManageScope: Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.E2eeActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
accent: Brush,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.scaleOnPress(interactionSource)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) { onClick() },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.background(accent, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeStreakETA(timestamp: Long): String? {
|
||||
val now = System.currentTimeMillis()
|
||||
val stringBuilder = StringBuilder()
|
||||
@@ -344,7 +405,6 @@ class ManageScope: Routes.Route() {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (context.config.root.experimental.e2eEncryption.globalState == true) {
|
||||
SectionTitle(translation["e2ee_title"])
|
||||
var hasSecretKey by rememberAsyncMutableState(defaultValue = false) {
|
||||
context.e2eeImplementation.friendKeyExists(friend.userId)
|
||||
}
|
||||
@@ -378,40 +438,100 @@ class ManageScope: Routes.Route() {
|
||||
}
|
||||
|
||||
ContentCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
if (hasSecretKey) {
|
||||
OutlinedButton(onClick = {
|
||||
context.coroutineScope.launch {
|
||||
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
|
||||
//TODO: fingerprint auth
|
||||
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
type = "text/plain"
|
||||
}, "").apply {
|
||||
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
||||
Intent().apply {
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
||||
})
|
||||
)
|
||||
})
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(14.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}) {
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = translation["export_base64_button"],
|
||||
maxLines = 1
|
||||
text = translation["e2ee_title"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["e2ee_subtitle"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (hasSecretKey) {
|
||||
E2eeActionButton(
|
||||
label = translation["export_base64_button"],
|
||||
icon = Icons.Filled.Lock,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.55f)
|
||||
)
|
||||
),
|
||||
onClick = {
|
||||
context.coroutineScope.launch {
|
||||
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@launch)
|
||||
//TODO: fingerprint auth
|
||||
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
type = "text/plain"
|
||||
}, "").apply {
|
||||
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
|
||||
Intent().apply {
|
||||
putExtra(Intent.EXTRA_TEXT, secretKey)
|
||||
putExtra(Intent.EXTRA_SUBJECT, secretKey)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(onClick = { importDialog = true }) {
|
||||
Text(
|
||||
text = translation["import_base64_button"],
|
||||
maxLines = 1
|
||||
E2eeActionButton(
|
||||
label = translation["import_base64_button"],
|
||||
icon = Icons.Filled.Lock,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
Color(0xFF7DD3FC),
|
||||
Color(0xFF818CF8)
|
||||
)
|
||||
),
|
||||
onClick = { importDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
|
||||
class MessagingPreview: Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview") }
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
private lateinit var previewScrollState: LazyListState
|
||||
|
||||
@@ -79,21 +79,6 @@ class MessagingPreview: Routes.Route() {
|
||||
else selectedMessages.add(messageId)
|
||||
}
|
||||
|
||||
private fun tr(key: String, fallback: String? = null): String {
|
||||
fun normalizeCandidate(candidate: String?): String? {
|
||||
if (candidate.isNullOrBlank()) return null
|
||||
if (candidate == key) return null
|
||||
if (candidate.endsWith(".$key")) return null
|
||||
return candidate
|
||||
}
|
||||
|
||||
return normalizeCandidate(translation.getOrNull(key))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
|
||||
?: fallback
|
||||
?: key
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionsSheetItem(
|
||||
title: String,
|
||||
@@ -338,20 +323,20 @@ class MessagingPreview: Routes.Route() {
|
||||
|
||||
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
|
||||
when {
|
||||
senderId == null -> "Unknown"
|
||||
senderId == myUserId -> "You"
|
||||
senderId == null -> translation["sender_unknown"]
|
||||
senderId == myUserId -> translation["sender_you"]
|
||||
scope == SocialScope.FRIEND -> friendDisplayName
|
||||
?: context.database.getFriendInfo(scopeId)?.displayName
|
||||
?: context.database.getFriendInfo(scopeId)?.mutableUsername
|
||||
?: "Friend"
|
||||
?: translation["sender_friend"]
|
||||
else -> context.database.getFriendInfo(senderId)?.displayName
|
||||
?: context.database.getFriendInfo(senderId)?.mutableUsername
|
||||
?: "Unknown"
|
||||
?: translation["sender_unknown"]
|
||||
}
|
||||
}
|
||||
|
||||
val contentTypeLabel = remember(contentType) {
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: translation["sender_unknown"]
|
||||
}
|
||||
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
|
||||
|
||||
@@ -408,7 +393,7 @@ class MessagingPreview: Routes.Route() {
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = senderDisplayName ?: "Unknown",
|
||||
text = senderDisplayName ?: translation["sender_unknown"],
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -487,7 +472,7 @@ class MessagingPreview: Routes.Route() {
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
|
||||
Text(translation["no_message_hint"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
@@ -564,7 +549,7 @@ class MessagingPreview: Routes.Route() {
|
||||
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: translation.getOrNull("processed_messages_toast")?.let {
|
||||
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue} messages"
|
||||
} ?: translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
context.longToast(toastText)
|
||||
}
|
||||
}
|
||||
@@ -579,7 +564,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -640,7 +625,7 @@ class MessagingPreview: Routes.Route() {
|
||||
) {
|
||||
val processedText = translation.getOrNull("processed_messages_text")?.let {
|
||||
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue}"
|
||||
} ?: translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
Text(processedText)
|
||||
if (activeTask?.hasFixedGoal() == true) {
|
||||
LinearProgressIndicator(
|
||||
@@ -679,7 +664,7 @@ class MessagingPreview: Routes.Route() {
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch messages", it)
|
||||
context.shortToast(
|
||||
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
|
||||
translation.getOrNull("message_fetch_failed") ?: translation["message_fetch_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -710,7 +695,7 @@ class MessagingPreview: Routes.Route() {
|
||||
fetchNewMessages()
|
||||
}.onFailure {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
|
||||
translation.getOrNull("bridge_init_failed") ?: translation["bridge_init_failed"]
|
||||
)
|
||||
context.log.error("Failed to initialize messaging bridge", it)
|
||||
}
|
||||
@@ -751,11 +736,11 @@ class MessagingPreview: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: translation["title"] ?: "Preview",
|
||||
title = titleText ?: translation["title"],
|
||||
subtitle = if (selectedMessages.isNotEmpty()) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
tr("subtitle", "Hold to select")
|
||||
translation["subtitle"]
|
||||
.substringBefore("•")
|
||||
.substringBefore("·")
|
||||
.substringBefore("|")
|
||||
@@ -798,7 +783,7 @@ class MessagingPreview: Routes.Route() {
|
||||
Text(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge",
|
||||
?: translation["bridge_connection_error"],
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White
|
||||
)
|
||||
@@ -831,7 +816,7 @@ class MessagingPreview: Routes.Route() {
|
||||
val selectionSubtitle = if (hasSelection) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
"Choose message types"
|
||||
translation["choose_message_types_subtitle"]
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -862,7 +847,7 @@ class MessagingPreview: Routes.Route() {
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = tr("actions_title", "Conversation Actions"),
|
||||
text = translation["actions_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
@@ -886,31 +871,31 @@ class MessagingPreview: Routes.Route() {
|
||||
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
|
||||
|
||||
ActionsSheetItem(
|
||||
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
|
||||
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
|
||||
title = translation[saveKey],
|
||||
subtitle = if (hasSelection) translation["save_selected_messages_subtitle"] else translation["save_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkAdded
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.SAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
|
||||
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
|
||||
title = translation[unsaveKey],
|
||||
subtitle = if (hasSelection) translation["unsave_selected_messages_subtitle"] else translation["unsave_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkBorder
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.UNSAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
|
||||
subtitle = "Marks snaps as seen",
|
||||
title = translation[markKey],
|
||||
subtitle = translation["mark_as_seen_subtitle"],
|
||||
icon = Icons.Rounded.RemoveRedEye
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
@@ -924,8 +909,8 @@ class MessagingPreview: Routes.Route() {
|
||||
runCurrentTask()
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
|
||||
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
|
||||
title = translation[deleteKey],
|
||||
subtitle = if (hasSelection) translation["delete_selected_messages_subtitle"] else translation["delete_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
danger = true
|
||||
) {
|
||||
@@ -933,7 +918,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
|
||||
@@ -10,18 +10,23 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
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.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -30,32 +35,54 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
class SocialRootSection : Routes.Route() {
|
||||
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
|
||||
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
|
||||
|
||||
private fun updateScopeLists() {
|
||||
internal fun updateScopeLists() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestLatestSnapshot() {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to request latest social snapshot", it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(scope: SocialScope) {
|
||||
internal fun ScopeList(
|
||||
scope: SocialScope,
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
|
||||
val list = when (scope) {
|
||||
SocialScope.GROUP -> groups
|
||||
SocialScope.FRIEND -> friends
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -63,11 +90,7 @@ class SocialRootSection : Routes.Route() {
|
||||
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
//check if scope list is empty
|
||||
val listSize = when (scope) {
|
||||
SocialScope.GROUP -> groupList.size
|
||||
SocialScope.FRIEND -> friendList.size
|
||||
}
|
||||
val listSize = list.size
|
||||
|
||||
if (listSize == 0) {
|
||||
item {
|
||||
@@ -76,14 +99,14 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
items(listSize) { index ->
|
||||
val id = when (scope) {
|
||||
SocialScope.GROUP -> groupList[index].conversationId
|
||||
SocialScope.FRIEND -> friendList[index].userId
|
||||
}
|
||||
val friend = if (scope == SocialScope.FRIEND) list[index] as MessagingFriendInfo else null
|
||||
val group = if (scope == SocialScope.GROUP) list[index] as MessagingGroupInfo else null
|
||||
val id = friend?.userId ?: group?.conversationId.orEmpty()
|
||||
|
||||
SocialCard(
|
||||
scope = scope,
|
||||
index = index,
|
||||
friend = friend,
|
||||
group = group,
|
||||
onManage = {
|
||||
routes.manageScope.navigate {
|
||||
put("id", id)
|
||||
@@ -175,50 +198,26 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateScopeLists()
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
SocialHeader(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = { index ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
friendCount = friendList.size,
|
||||
groupCount = groupList.size
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ScopeList(SocialScope.FRIEND)
|
||||
1 -> ScopeList(SocialScope.GROUP)
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@SocialRootSection.SocialScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialCard(
|
||||
internal fun SocialCard(
|
||||
scope: SocialScope,
|
||||
index: Int,
|
||||
friend: MessagingFriendInfo?,
|
||||
group: MessagingGroupInfo?,
|
||||
onManage: () -> Unit,
|
||||
onPreview: () -> Unit,
|
||||
remainingHours: Int
|
||||
@@ -249,7 +248,7 @@ class SocialRootSection : Routes.Route() {
|
||||
) {
|
||||
when (scope) {
|
||||
SocialScope.GROUP -> {
|
||||
val group = groupList[index]
|
||||
val groupInfo = group ?: return@Row
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
@@ -267,7 +266,7 @@ class SocialRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = group.name,
|
||||
text = groupInfo.name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -283,16 +282,16 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
SocialScope.FRIEND -> {
|
||||
val friend = friendList[index]
|
||||
val streaks by rememberAsyncMutableState(defaultValue = friend.streaks) {
|
||||
context.database.getFriendStreaks(friend.userId)
|
||||
val friendInfo = friend ?: return@Row
|
||||
val streaks by rememberAsyncMutableState(defaultValue = friendInfo.streaks) {
|
||||
context.database.getFriendStreaks(friendInfo.userId)
|
||||
}
|
||||
|
||||
BitmojiImage(
|
||||
context = context,
|
||||
url = BitmojiSelfie.getBitmojiSelfie(
|
||||
friend.selfieId,
|
||||
friend.bitmojiId,
|
||||
friendInfo.selfieId,
|
||||
friendInfo.bitmojiId,
|
||||
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
|
||||
)
|
||||
)
|
||||
@@ -302,7 +301,7 @@ class SocialRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = friend.displayName ?: friend.mutableUsername,
|
||||
text = friendInfo.displayName ?: friendInfo.mutableUsername,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -310,7 +309,7 @@ class SocialRootSection : Routes.Route() {
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = friend.mutableUsername,
|
||||
text = friendInfo.mutableUsername,
|
||||
maxLines = 1,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
@@ -378,12 +377,14 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialHeader(
|
||||
internal fun SocialHeader(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
friendCount: Int,
|
||||
groupCount: Int
|
||||
groupCount: Int,
|
||||
searchActive: Boolean,
|
||||
onSearchToggle: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -418,7 +419,7 @@ class SocialRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.social"] ?: "Social",
|
||||
text = translation["manager.routes.social"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -429,8 +430,15 @@ class SocialRootSection : Routes.Route() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StatPill(label = "Friends", value = friendCount)
|
||||
StatPill(label = "Groups", value = groupCount)
|
||||
StatPill(label = translation["friends_tab"], value = friendCount)
|
||||
StatPill(label = translation["groups_tab"], value = groupCount)
|
||||
IconButton(onClick = onSearchToggle) {
|
||||
Icon(
|
||||
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SocialTabSwitcher(
|
||||
@@ -443,7 +451,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialTabSwitcher(
|
||||
internal fun SocialTabSwitcher(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -489,7 +497,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(scope: SocialScope) {
|
||||
internal fun EmptyState(scope: SocialScope) {
|
||||
val title = when (scope) {
|
||||
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
|
||||
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
|
||||
@@ -516,13 +524,13 @@ class SocialRootSection : Routes.Route() {
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
text = title ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
|
||||
text = translation["social_empty_hint"] ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -531,7 +539,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatPill(label: String, value: Int) {
|
||||
internal fun StatPill(label: String, value: Int) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
@@ -560,3 +568,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
@Composable
|
||||
fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
|
||||
val horizontalPadding = 24.dp
|
||||
val bottomPadding = routes.bottomPadding
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
|
||||
|
||||
LaunchedEffect(scrollState.value) {
|
||||
routes.navigation?.globalScrollOffset = scrollState.value
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = bottomPadding + 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(controlsHeight))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().background(PurrfectPalette.panelGradient)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_title"] ?: "About",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontFamily = avenirNext,
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = tapSource,
|
||||
indication = null,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.longValue > tapTimeoutMs) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation["about_tagline"] ?: "",
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = translation["about_lead_developers_title"] ?: "Lead Developers",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LITERAL OUR STORY VERBATIM RESTORATION (Justified & Indented)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PurrfectMarqueeText(
|
||||
text = translation["about_story_title"] ?: "About Us",
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(0.7f).padding(vertical = 4.dp),
|
||||
thickness = 1.2.dp,
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = aboutStory,
|
||||
fontSize = 15.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Justify,
|
||||
style = LocalTextStyle.current.copy(
|
||||
lineHeight = 22.sp,
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = translation["about_thanks_title"] ?: "Special Thanks", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = scrollState.value,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.toPropertyPair
|
||||
|
||||
@Composable
|
||||
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
when (currentDestination?.route) {
|
||||
FEATURE_CONTAINER_ROUTE -> {
|
||||
val containerName = navBackStackEntry?.arguments?.getString("name")!!
|
||||
val propertyPair = allContainers[containerName]!!
|
||||
Container(
|
||||
configContainer = propertyPair.value.get() as ConfigContainer,
|
||||
stateKey = "${routeInfo.id}:container:$containerName",
|
||||
sectionTitle = context.translation[propertyPair.key.propertyName()],
|
||||
sectionSubtitle = context.translation[propertyPair.key.propertyDescription()],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
SEARCH_FEATURE_ROUTE -> {
|
||||
val keyword = navBackStackEntry?.arguments?.getString("keyword").orEmpty()
|
||||
val properties = allProperties.filter {
|
||||
isSearchVisibleProperty(it.key) && (
|
||||
it.key.name.contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { (it.key to it.value).toPropertyPair() }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
stateKey = "${routeInfo.id}:search:$keyword",
|
||||
isSearchResults = true,
|
||||
searchKeyword = keyword,
|
||||
enableGlobalSearch = true,
|
||||
onBack = { navigateToMainRoot() }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Container(
|
||||
configContainer = context.config.root,
|
||||
stateKey = "${routeInfo.id}:container:root"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,824 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Widgets
|
||||
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.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.getQuickTiles
|
||||
import me.eternal.purrfectsnap.storage.setQuickTiles
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
|
||||
@Composable
|
||||
fun LivingPurrAura(isActive: Boolean, haptic: HapticFeedback) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "aura")
|
||||
val pulseScale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.88f, targetValue = 1.12f,
|
||||
animationSpec = infiniteRepeatable(tween(1600, easing = EaseInOutSine), RepeatMode.Reverse),
|
||||
label = "pulse"
|
||||
)
|
||||
val glow1 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f, targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(3200, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "g1"
|
||||
)
|
||||
val glow2 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f, targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 1100, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "g2"
|
||||
)
|
||||
val glow3 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f, targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 2200, easing = LinearEasing), RepeatMode.Restart),
|
||||
label = "g3"
|
||||
)
|
||||
val coreColor by animateColorAsState(
|
||||
targetValue = if (isActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3),
|
||||
animationSpec = tween(800), label = "coreColor"
|
||||
)
|
||||
val secondaryColor by animateColorAsState(
|
||||
targetValue = if (isActive) PurrfectPalette.glowSecondary else Color(0xFF6B6B7A),
|
||||
animationSpec = tween(800), label = "secondaryColor"
|
||||
)
|
||||
Canvas(modifier = Modifier.size(44.dp)) {
|
||||
val center = Offset(size.width / 2, size.height / 2)
|
||||
val baseRadius = 6.dp.toPx()
|
||||
fun drawAuroraGlow(progress: Float, alphaMultiplier: Float) {
|
||||
if (!isActive || progress <= 0f) return
|
||||
val auroraRadius = baseRadius * (1.2f + 4.5f * progress)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
0.0f to coreColor.copy(alpha = 0.25f * (1f - progress) * alphaMultiplier),
|
||||
0.6f to secondaryColor.copy(alpha = 0.12f * (1f - progress) * alphaMultiplier),
|
||||
1.0f to Color.Transparent,
|
||||
center = center, radius = auroraRadius
|
||||
),
|
||||
radius = auroraRadius, center = center
|
||||
)
|
||||
}
|
||||
drawAuroraGlow(glow1, 0.8f)
|
||||
drawAuroraGlow(glow2, 0.5f)
|
||||
drawAuroraGlow(glow3, 0.3f)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(coreColor, secondaryColor),
|
||||
center = center, radius = baseRadius * pulseScale
|
||||
),
|
||||
radius = baseRadius * pulseScale, center = center
|
||||
)
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
radius = (baseRadius * pulseScale) * 0.25f,
|
||||
center = Offset(center.x - (baseRadius * pulseScale) * 0.3f, center.y - (baseRadius * pulseScale) * 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AphelionTopBarActionChip(
|
||||
icon: ImageVector,
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
shrinkFactor: Float = 1f,
|
||||
haptic: HapticFeedback,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
|
||||
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp).graphicsLayer {
|
||||
val s = 0.82f + (0.18f * shrinkFactor); scaleX = s; scaleY = s
|
||||
}
|
||||
)
|
||||
if (label != null) {
|
||||
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
|
||||
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White.copy(alpha = labelAlpha),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||
maxLines = 1, overflow = TextOverflow.Clip,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.widthIn(max = (75 * shrinkFactor).dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RowScope.AphelionHomeActionChips(
|
||||
scrollState: androidx.compose.foundation.ScrollState,
|
||||
haptic: HapticFeedback
|
||||
) {
|
||||
val shrinkFactor by remember(scrollState.value) {
|
||||
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
|
||||
}
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.homeLogs.navigate() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Settings,
|
||||
label = context.translation["manager.routes.home_settings"],
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.settings.navigate() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AphelionHeroSection(
|
||||
versionName: String,
|
||||
latestUpdate: Updater.LatestRelease?,
|
||||
downloadState: UpdateDownloader.DownloadState,
|
||||
downloadProgress: Float,
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onAboutClick: () -> Unit,
|
||||
avenirNext: FontFamily,
|
||||
scrollOffset: () -> Int,
|
||||
haptic: HapticFeedback
|
||||
) {
|
||||
val heroShape = RoundedCornerShape(36.dp)
|
||||
val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = HomeRootSection.cardMargin, vertical = 6.dp)
|
||||
.clip(heroShape)
|
||||
.background(Brush.linearGradient(heroGradientColors))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.1f), heroShape)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, fontFamily = avenirNext,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = (1f - ((scrollOffset() - 250f) / 120f)).coerceIn(0f, 1f)
|
||||
translationY = (-scrollOffset() * 0.06f)
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = "By \u039eT\u039eRNAL",
|
||||
color = Color.White.copy(alpha = 0.75f), fontSize = 14.sp, fontFamily = avenirNext,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = (1f - ((scrollOffset() - 300f) / 120f)).coerceIn(0f, 1f)
|
||||
translationY = (-scrollOffset() * 0.04f)
|
||||
}
|
||||
)
|
||||
Text(
|
||||
text = translation["hero_tagline"] ?: "",
|
||||
color = Color.White.copy(alpha = 0.9f), fontSize = 15.sp, lineHeight = 20.sp, textAlign = TextAlign.Center,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = (1f - ((scrollOffset() - 350f) / 120f)).coerceIn(0f, 1f)
|
||||
translationY = (-scrollOffset() * 0.02f)
|
||||
}
|
||||
)
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().graphicsLayer {
|
||||
alpha = (1f - ((scrollOffset() - 400f) / 120f)).coerceIn(0f, 1f)
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
|
||||
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
|
||||
HeroBadge(translation.format("hero_build_label", "build" to it))
|
||||
}
|
||||
}
|
||||
|
||||
if (latestUpdate != null) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(translation["update_title"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
||||
Text(translation.format("update_content", "version" to latestUpdate.versionName), color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp)
|
||||
}
|
||||
AnimatedContent(targetState = downloadState, label = "UpdateDownloadHero") { state ->
|
||||
when (state) {
|
||||
UpdateDownloader.DownloadState.IDLE,
|
||||
UpdateDownloader.DownloadState.FAILED -> {
|
||||
Button(
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onUpdateAction() },
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
|
||||
) { Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) }
|
||||
}
|
||||
UpdateDownloader.DownloadState.DOWNLOADING -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
CircularProgressIndicator(progress = { downloadProgress }, modifier = Modifier.size(28.dp), color = Color.White)
|
||||
Text("${(downloadProgress * 100).toInt()}%", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
UpdateDownloader.DownloadState.COMPLETED -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFFA3F0C2))
|
||||
Text(translation["update_ready_label"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)),
|
||||
tonalElevation = 0.dp, shadowElevation = 0.dp
|
||||
) {
|
||||
val unifiedButtonWidth = 180.dp
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(50),
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
|
||||
modifier = Modifier.width(unifiedButtonWidth).height(46.dp),
|
||||
tonalElevation = 0.dp, shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) {
|
||||
LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (isPurrAuraActive) translation["purr_aura_active_label"] ?: "" else translation["purr_aura_inactive_label"] ?: "",
|
||||
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onAboutClick() },
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = Color.White.copy(alpha = 0.06f), contentColor = Color.White),
|
||||
modifier = Modifier.width(unifiedButtonWidth).height(46.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Info, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation.getOrNull("about_meet_team_button") ?: "About Us", fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
val androidContext = context.androidContext
|
||||
Button(
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://purrfectsnap.me", context.translation["toast_open_link_failed"]) },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
PurrfectMarqueeText(text = "Site", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color(0xFF1B152E))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f).height(44.dp),
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) },
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
PurrfectMarqueeText(text = translation["github_button"] ?: "", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color.White)
|
||||
}
|
||||
}
|
||||
ExternalLinkIcon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) },
|
||||
tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f),
|
||||
haptic = haptic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) }
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
|
||||
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
val hasInitialized = prefs.getBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, false)
|
||||
when {
|
||||
storedTiles.isNotEmpty() -> {
|
||||
if (!hasInitialized) prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
storedTiles
|
||||
}
|
||||
hasInitialized -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
|
||||
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
|
||||
}
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
|
||||
val scrollState = rememberScrollState()
|
||||
var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var changelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var changelogLoading by remember { mutableStateOf(false) }
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
LaunchedEffect(scrollState.value) { routes.navigation?.globalScrollOffset = scrollState.value }
|
||||
|
||||
val handleUpdateAction: () -> Unit = {
|
||||
latestUpdate?.let { latest ->
|
||||
val abiName = android.os.Build.SUPPORTED_ABIS.firstNotNullOfOrNull {
|
||||
when (it) { "arm64-v8a" -> "arm64"; "armeabi-v7a" -> "armv7"; else -> null }
|
||||
}
|
||||
if (latest.workflowId != null) {
|
||||
if (abiName != null) {
|
||||
val artifactName = "purrfectsnap-${if (abiName == "arm64") "armv8" else "armv7"}-debug"
|
||||
UpdateDownloader.downloadAndInstall(context, "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip", "$artifactName.zip", coroutineScope)
|
||||
}
|
||||
} else {
|
||||
abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url ->
|
||||
UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChangelog() {
|
||||
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
|
||||
if (changelogVersion == targetVersion && changelogText != null) return
|
||||
changelogLoading = true
|
||||
changelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogText = text
|
||||
changelogVersion = targetVersion
|
||||
changelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogError = e.message ?: "Failed to fetch"
|
||||
changelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAnnouncements() {
|
||||
if (announcementsText != null) return
|
||||
announcementsLoading = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(announcementsUrl).build()).execute().use { it.body?.string() ?: "" }
|
||||
}
|
||||
.onSuccess { withContext(Dispatchers.Main) { announcementsText = it; announcementsLoading = false } }
|
||||
.onFailure { withContext(Dispatchers.Main) { announcementsLoading = false } }
|
||||
}
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(HomeRootSection.pageBackgroundGradient)) {
|
||||
|
||||
val focusFactor by remember(scrollState.value) {
|
||||
derivedStateOf { (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
|
||||
}
|
||||
val stickyBrandingAlpha by remember(scrollState.value) {
|
||||
derivedStateOf { ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f) }
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val headerHeight = lerp(54.dp, 56.dp, focusFactor)
|
||||
val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
|
||||
val topCorners = lerp(26.dp, 0.dp, focusFactor)
|
||||
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) {
|
||||
val refractiveColor = remember { Color(0xFF241F52) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = containerTopPadding)
|
||||
.height(internalTopPadding + headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
|
||||
1.0f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = containerTopPadding)
|
||||
.headerHeightTracker { controlsHeight = it }
|
||||
.drawBehind {
|
||||
val strokeWidth = 1.dp.toPx()
|
||||
val brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = focusFactor * 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = focusFactor * 0.4f)
|
||||
)
|
||||
)
|
||||
val tr = topCorners.toPx()
|
||||
val br = bottomCorners.toPx()
|
||||
if (focusFactor > 0.9f) {
|
||||
uPath.reset()
|
||||
uPath.apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(0f, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 180f, -90f, false)
|
||||
lineTo(size.width - br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 90f, -90f, false)
|
||||
lineTo(size.width, 0f)
|
||||
}
|
||||
drawPath(uPath, brush, style = Stroke(strokeWidth))
|
||||
} else if (focusFactor > 0.01f) {
|
||||
borderPath.reset()
|
||||
borderPath.apply {
|
||||
moveTo(tr, 0f)
|
||||
lineTo(size.width - tr, 0f)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * tr, 0f, size.width, 2 * tr), 270f, 90f, false)
|
||||
lineTo(size.width, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 0f, 90f, false)
|
||||
lineTo(br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 90f, 90f, false)
|
||||
lineTo(0f, tr)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2 * tr, 2 * tr), 180f, 90f, false)
|
||||
}
|
||||
drawPath(borderPath, brush, style = Stroke(strokeWidth))
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = bottomCorners, bottomEnd = bottomCorners),
|
||||
color = Color(0xFF1B152E).copy(alpha = focusFactor * 0.95f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = internalTopPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(headerHeight)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = Color.White.copy(alpha = stickyBrandingAlpha),
|
||||
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
}
|
||||
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = routes.bottomPadding + 4.dp)) {
|
||||
Spacer(Modifier.height(controlsHeight + containerTopPadding))
|
||||
|
||||
AphelionHeroSection(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
latestUpdate = latestUpdate,
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onAboutClick = { routes.about.navigate() },
|
||||
avenirNext = avenirNext,
|
||||
scrollOffset = { scrollState.value },
|
||||
haptic = haptic
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActions") { hasQuickActions ->
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = HomeRootSection.cardMargin, vertical = 10.dp),
|
||||
shape = RoundedCornerShape(34.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(quickActionsGradientColors)).padding(horizontal = 24.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!hasQuickActions) {
|
||||
Text(translation["quick_actions_title"] ?: "", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.85f))
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Icon(Icons.Outlined.Widgets, contentDescription = null, modifier = Modifier.size(72.dp), tint = Color.White)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(translation["quick_actions_empty_title"] ?: "", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(translation["quick_actions_add_tile_button"] ?: "")
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(translation["quick_actions_title"] ?: "", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||
Text(translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()), fontSize = 13.sp, color = Color.White.copy(alpha = 0.75f))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedButton(
|
||||
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(translation["quick_actions_manage_button"] ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
var gridIsVisible by remember { mutableStateOf(false) }
|
||||
var animationPhase by remember { mutableIntStateOf(1) }
|
||||
LaunchedEffect(gridIsVisible) {
|
||||
if (gridIsVisible) {
|
||||
delay(600); animationPhase = 2
|
||||
delay(1200); animationPhase = 3
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords ->
|
||||
val windowHeight = context.androidContext.resources.displayMetrics.heightPixels
|
||||
val posY = coords.localToWindow(Offset.Zero).y
|
||||
if (posY > 0 && posY < windowHeight * 0.95f) gridIsVisible = true
|
||||
}
|
||||
) {
|
||||
val columns = (maxWidth / 110.dp).toInt().coerceIn(2, 4)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
maxItemsInEachRow = columns
|
||||
) {
|
||||
selectedTiles.forEach { name ->
|
||||
val cardEntry = cards.entries.find { it.key.first == name } ?: return@forEach
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val animatedIconSize by animateDpAsState(
|
||||
targetValue = if (animationPhase >= 2) 28.dp else 44.dp,
|
||||
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
|
||||
label = "iconShrink"
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.width(100.dp).aspectRatio(1.05f).scaleOnPress(interactionSource)
|
||||
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); cardEntry.value(routes) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.3f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f)))).clipToBounds()) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(cardEntry.key.second, contentDescription = null, tint = Color.White, modifier = Modifier.size(animatedIconSize))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PurrfectMarqueeText(
|
||||
text = cardEntry.key.first,
|
||||
style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showAnnouncementsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showAnnouncementsDialog = false },
|
||||
title = translation["announcements_dialog_title"] ?: "Announcements",
|
||||
text = "", icon = Icons.Filled.Notifications,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "", icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
|
||||
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
selectedQuickActions = selectedTiles,
|
||||
onDismiss = { showQuickActionsMenu = false },
|
||||
onSave = { newList ->
|
||||
val removed = selectedTiles.filter { it !in newList }
|
||||
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
|
||||
selectedTiles.clear(); selectedTiles.addAll(newList)
|
||||
context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) }
|
||||
showQuickActionsMenu = false
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
isRefreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val readerResult = runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
val filteredLogs = (0 until reader.lineCount).mapNotNull { index ->
|
||||
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
visibleLogs.clear()
|
||||
visibleLogs.addAll(filteredLogs)
|
||||
if (visibleLogs.isNotEmpty()) {
|
||||
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
refreshLogs()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
refreshLogs()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (visibleLogs.isEmpty() && logReader != null) {
|
||||
EmptyLogsState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = logListState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = controlsHeight,
|
||||
bottom = routes.bottomPadding + 12.dp
|
||||
)
|
||||
) {
|
||||
items(visibleLogs, key = { it.hashCode() }) { line ->
|
||||
LogEntryCard(line = line, composeContext = composeContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = context.translation["manager.routes.home_logs"] ?: "Logs",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { refreshLogs() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showDropDown = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
clearLogsAndReload()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
|
||||
text = { Text(translation["clear_logs_button"] ?: "Clear", color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
exportLogs()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
text = { Text(translation["export_logs_button"] ?: "Export", color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ScriptingRootSection.AphelionScriptingScreen(nav: NavBackStackEntry) {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
scriptingFolder?.let {
|
||||
context.androidContext.openLink(
|
||||
it.uri.toString(),
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/SnapEnhance/scripting-docs",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.07f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
val computedScrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
|
||||
else listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(computedScrollOffset) {
|
||||
routes.navigation?.globalScrollOffset = computedScrollOffset
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
if (showResetSetupDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showResetSetupDialog = false },
|
||||
title = translation["reset_setup_dialog_title"],
|
||||
text = translation["reset_setup_dialog_text"],
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = context.translation["button.positive"],
|
||||
dismissButtonText = context.translation["button.negative"],
|
||||
onConfirm = {
|
||||
showResetSetupDialog = false
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
val intent = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showResetSetupDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.height(controlsHeight))
|
||||
}
|
||||
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// THEME SWITCHER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
|
||||
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
|
||||
var localThemeId by remember { mutableStateOf(currentThemeId) }
|
||||
|
||||
Switch(
|
||||
checked = localThemeId == "APHELION",
|
||||
onCheckedChange = { isAphelion ->
|
||||
val newId = if (isAphelion) "APHELION" else "LEGACY"
|
||||
localThemeId = newId // Update UI instantly
|
||||
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
// 1. Capture bitmap BEFORE theme change
|
||||
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||
|
||||
// 2. Request Reveal
|
||||
routes.navigation?.themeRevealState?.requestReveal(
|
||||
newThemeId = newId,
|
||||
originCenter = switchCenter,
|
||||
bitmap = bitmap
|
||||
)
|
||||
|
||||
// 3. Apply theme and persist
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(50)
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
|
||||
// Write to disk immediately on IO thread and finish
|
||||
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
writeJob.join() // Ensure it finishes its work before scope potentially closes
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 26.dp)
|
||||
.onGloballyPositioned { coords ->
|
||||
val rootPos = coords.positionInRoot()
|
||||
switchCenter = androidx.compose.ui.geometry.Offset(
|
||||
x = rootPos.x + coords.size.width / 2f,
|
||||
y = rootPos.y + coords.size.height / 2f
|
||||
)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
// UI SETTINGS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
|
||||
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RESET SETUP
|
||||
GlassCard {
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// MESSAGE LOGGER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
}
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FRIEND NOTES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
|
||||
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = computedScrollOffset,
|
||||
enableMorph = true,
|
||||
titleAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
routes.navigation?.openBottomBarCustomization = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
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.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateScopeLists()
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
} else {
|
||||
friendList.filter {
|
||||
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
|
||||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredGroups = remember(groupList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
groupList
|
||||
} else {
|
||||
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
SocialHeader(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = { index ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
friendCount = friendList.size,
|
||||
groupCount = groupList.size,
|
||||
searchActive = searchActive,
|
||||
onSearchToggle = {
|
||||
searchActive = !searchActive
|
||||
if (!searchActive) searchQuery = ""
|
||||
}
|
||||
)
|
||||
if (searchActive) {
|
||||
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
|
||||
val searchShape = RoundedCornerShape(18.dp)
|
||||
val searchBorder = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
shape = searchShape,
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, searchBorder),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, searchShape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = searchHint,
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
BasicTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
),
|
||||
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { innerTextField ->
|
||||
if (searchQuery.isEmpty()) {
|
||||
Text(
|
||||
text = searchHint,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = translation["clear_search_button_description"] ?: "Clear",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
|
||||
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
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.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.task.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
|
||||
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
val scrollState = rememberLazyListState()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
|
||||
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
|
||||
routes.navigation?.globalScrollOffset = offset
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchActiveTasks(this)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskSelection.clear()
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
fetchActiveTasks(scope)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
"active" to activeTasks.size.toString(),
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
} else {
|
||||
translation.format(
|
||||
"summary_idle",
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
}
|
||||
|
||||
// The "Structured Glass" Container (1:1 with build 33a7e8f)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 12.dp),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = controlsHeight,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
|
||||
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
|
||||
}
|
||||
}
|
||||
items(activeTasks, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(recentTasks, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
||||
title = context.translation["manager.routes.tasks"] ?: "Tasks",
|
||||
subtitle = subtitle,
|
||||
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PlaylistAddCheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = activeTasks.size.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
|
||||
Surface(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
mergeSelection(
|
||||
taskSelection.toList().also { taskSelection.clear() }
|
||||
.map { it.first to it.second!! }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfirmDialog) {
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
} else {
|
||||
translation["remove_all_tasks_confirm"]
|
||||
}
|
||||
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
|
||||
|
||||
TaskDangerDialog(
|
||||
visible = showConfirmDialog,
|
||||
title = titleText,
|
||||
message = messageText,
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
clearTasks(alsoDeleteFiles, scope)
|
||||
},
|
||||
onDismiss = { showConfirmDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TasksRootSection.AphelionTasksEmptyState(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = text,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
|
||||
var taskStatus by remember { mutableStateOf(task.status) }
|
||||
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
|
||||
var taskProgress by remember { mutableIntStateOf(-1) }
|
||||
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
|
||||
|
||||
var documentFileMimeType by remember { mutableStateOf("") }
|
||||
var isDocumentFileReadable by remember { mutableStateOf(true) }
|
||||
|
||||
val docVal = task.extra?.toUri()
|
||||
val documentFile by rememberAsyncMutableState(
|
||||
defaultValue = null as DocumentFile?,
|
||||
keys = arrayOf(taskStatus.name)
|
||||
) {
|
||||
if (docVal == null) null
|
||||
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
|
||||
documentFileMimeType = type ?: ""
|
||||
isDocumentFileReadable = canRead()
|
||||
}
|
||||
}
|
||||
|
||||
val listener = remember { PendingTaskListener(
|
||||
onStateChange = { taskStatus = it },
|
||||
onProgress = { label, progress ->
|
||||
taskProgressLabel = label
|
||||
taskProgress = progress
|
||||
}
|
||||
) }
|
||||
|
||||
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
|
||||
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val isActive = pendingTask != null && !taskStatus.isFinalStage()
|
||||
|
||||
fun toggleSelection() {
|
||||
if (isSelected) {
|
||||
taskSelection.removeIf { it.first == task }
|
||||
return
|
||||
}
|
||||
taskSelection.add(task to documentFile)
|
||||
}
|
||||
|
||||
fun openFile() {
|
||||
if (!isDocumentFileReadable || documentFile == null) return
|
||||
runCatching {
|
||||
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(documentFile!!.uri, documentFile!!.type)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
})
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file ${documentFile?.uri}", it)
|
||||
context.shortToast(translation["failed_to_open_file"] ?: "Failed to open file")
|
||||
}
|
||||
}
|
||||
|
||||
val cardModifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
toggleSelection()
|
||||
return@detectTapGestures
|
||||
}
|
||||
openFile()
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
openFile()
|
||||
return@detectTapGestures
|
||||
}
|
||||
toggleSelection()
|
||||
}
|
||||
)
|
||||
}
|
||||
.let {
|
||||
if (isSelected) {
|
||||
it.border(2.dp, PurrfectPalette.glowSecondary, RoundedCornerShape(22.dp)).clip(RoundedCornerShape(22.dp))
|
||||
} else it
|
||||
}
|
||||
|
||||
val chipLabel = when {
|
||||
isActive -> translation.getOrNull("task_sending") ?: "Sending"
|
||||
taskStatus == TaskStatus.SUCCESS -> null
|
||||
taskStatus == TaskStatus.FAILURE -> translation.getOrNull("task_failed") ?: "Failed"
|
||||
taskStatus == TaskStatus.CANCELLED -> translation.getOrNull("task_cancelled") ?: "Cancelled"
|
||||
else -> taskStatus.name.lowercase().replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
val chipIcon = when {
|
||||
isActive -> null
|
||||
taskStatus == TaskStatus.SUCCESS -> Icons.Filled.Check
|
||||
taskStatus == TaskStatus.FAILURE -> Icons.Filled.WarningAmber
|
||||
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
|
||||
else -> Icons.Filled.Info
|
||||
}
|
||||
val chipColors = when {
|
||||
isActive -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
|
||||
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.06f),
|
||||
labelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
else -> AssistChipDefaults.assistChipColors()
|
||||
}
|
||||
val countdownText = if (isActive) {
|
||||
taskProgressLabel?.let { label ->
|
||||
Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label
|
||||
}
|
||||
} else null
|
||||
|
||||
val cardShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) else SolidColor(Color.White.copy(alpha = 0.1f)))
|
||||
) {
|
||||
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
|
||||
var loadFailed by remember { mutableStateOf(false) }
|
||||
val doc = documentFile
|
||||
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
|
||||
val imageRequest = ImageRequest.Builder(context.androidContext)
|
||||
.data(doc.uri)
|
||||
.cacheKey(doc.uri.toString())
|
||||
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
|
||||
.build()
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = imageRequest,
|
||||
imageLoader = context.imageLoader,
|
||||
onState = { state ->
|
||||
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
|
||||
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
|
||||
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
|
||||
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
|
||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (task.type == TaskType.SCHEDULED_SEND) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", style = MaterialTheme.typography.labelMedium, color = PurrfectPalette.textSecondary)
|
||||
Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
task.author?.takeIf { it != "null" }?.let { recipients ->
|
||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Icon(Icons.Filled.People, contentDescription = null, modifier = Modifier.size(16.dp).padding(top = 2.dp), tint = PurrfectPalette.textSecondary)
|
||||
recipients.split(", ").let { list ->
|
||||
Text(list.joinToString(", "), style = MaterialTheme.typography.bodyMedium, color = Color.White, lineHeight = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
task.author?.takeIf { it != "null" }?.let {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(top = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
chipLabel?.let { label ->
|
||||
val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) {
|
||||
{ Icon(Icons.Filled.Timer, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
} else if (isActive) {
|
||||
countdownText?.let { countdown -> { Text(countdown, style = MaterialTheme.typography.labelSmall) } }
|
||||
} else chipIcon?.let { icon -> { Icon(icon, contentDescription = null) } }
|
||||
|
||||
val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) {
|
||||
translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText"
|
||||
} else label
|
||||
|
||||
AssistChip(onClick = {}, enabled = false, leadingIcon = leadingIcon, label = { Text(displayLabel) }, colors = chipColors)
|
||||
}
|
||||
|
||||
if (!taskStatus.isFinalStage()) {
|
||||
if (!isActive) {
|
||||
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
|
||||
}
|
||||
if (taskProgress != -1 && taskProgressLabel == null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { taskProgress.toFloat() / 100f },
|
||||
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),
|
||||
color = PurrfectPalette.glowSecondary, trackColor = Color.White.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
if (!isActive) {
|
||||
task.extra?.takeIf { it.isNotEmpty() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
if (isActive) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
runCatching { pendingTask?.cancel() }.onFailure { throwable ->
|
||||
context.log.error("Failed to cancel task $pendingTask", throwable)
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), contentColor = Color.White)
|
||||
) { Icon(Icons.Filled.Close, contentDescription = "Cancel") }
|
||||
} else if (taskStatus == TaskStatus.SUCCESS) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(tween(250)) + scaleIn(tween(300)),
|
||||
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150))
|
||||
) {
|
||||
Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (taskStatus) {
|
||||
TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B))
|
||||
TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ThemeContract
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
|
||||
object AphelionTheme : ThemeContract {
|
||||
@Composable
|
||||
override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) {
|
||||
AphelionHomeScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) {
|
||||
AphelionSettingsScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) {
|
||||
AphelionAboutScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
|
||||
AphelionLogsScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) {
|
||||
AphelionSocialScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
|
||||
AphelionTasksScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) {
|
||||
AphelionFeaturesScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) {
|
||||
AphelionScriptingScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) {
|
||||
TrackerScreenContent(nav)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,14 @@ import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
|
||||
class EditRule : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
|
||||
private data class RuleSnapshot(
|
||||
val name: String,
|
||||
val author: String,
|
||||
val scopes: List<String>,
|
||||
val events: List<TrackerRuleEvent>,
|
||||
val scopeType: TrackerScopeType
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RuleCard(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -174,11 +182,11 @@ class EditRule : Routes.Route() {
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = { expanded.value = !expanded.value },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded.value,
|
||||
onExpandedChange = { expanded.value = !expanded.value },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
val eventLabel = context.translation["tracker_events.${currentEventType.value}"]
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -186,8 +194,8 @@ class EditRule : Routes.Route() {
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.widthIn(min = 240.dp),
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.wrapContentWidth(),
|
||||
value = eventLabel,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
@@ -207,7 +215,7 @@ class EditRule : Routes.Route() {
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded.value,
|
||||
onDismissRequest = { expanded.value = false },
|
||||
modifier = Modifier.widthIn(min = 240.dp),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
containerColor = Color(0xFF121528),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
@@ -295,24 +303,45 @@ class EditRule : Routes.Route() {
|
||||
val authorName = rememberAsyncMutableState<String>(defaultValue = "", keys = arrayOf(currentRuleId)) {
|
||||
currentRuleId?.let { ruleId -> context.database.getTrackerRule(ruleId)?.author ?: "" } ?: ""
|
||||
}
|
||||
val initialRuleState by remember(ruleName.value.isNotBlank() || events.isNotEmpty() || scopes.isNotEmpty()) {
|
||||
mutableStateOf(
|
||||
mapOf(
|
||||
"name" to ruleName.value,
|
||||
"author" to authorName.value,
|
||||
"scopes" to scopes.toList(),
|
||||
"events" to events.toList(),
|
||||
"scopeType" to currentScopeType
|
||||
)
|
||||
)
|
||||
fun snapshotEvents() = events.map { event ->
|
||||
event.copy(params = event.params.copy(), actions = event.actions.toList())
|
||||
}
|
||||
fun buildSnapshot() = RuleSnapshot(
|
||||
name = ruleName.value,
|
||||
author = authorName.value,
|
||||
scopes = scopes.toList(),
|
||||
events = snapshotEvents(),
|
||||
scopeType = currentScopeType
|
||||
)
|
||||
val initialSnapshot = remember(currentRuleId) {
|
||||
mutableStateOf<RuleSnapshot?>(if (currentRuleId == null) buildSnapshot() else null)
|
||||
}
|
||||
LaunchedEffect(
|
||||
currentRuleId,
|
||||
ruleName.value,
|
||||
authorName.value,
|
||||
events.size,
|
||||
scopes.size,
|
||||
currentScopeType
|
||||
) {
|
||||
if (initialSnapshot.value == null) {
|
||||
val hasLoaded = ruleName.value.isNotBlank() ||
|
||||
authorName.value.isNotBlank() ||
|
||||
events.isNotEmpty() ||
|
||||
scopes.isNotEmpty()
|
||||
if (hasLoaded) {
|
||||
initialSnapshot.value = buildSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
val isDirty by remember {
|
||||
derivedStateOf {
|
||||
initialRuleState["name"] != ruleName.value ||
|
||||
initialRuleState["author"] != authorName.value ||
|
||||
initialRuleState["scopes"] != scopes.toList() ||
|
||||
initialRuleState["events"] != events.toList() ||
|
||||
initialRuleState["scopeType"] != currentScopeType
|
||||
val snapshot = initialSnapshot.value ?: return@derivedStateOf false
|
||||
snapshot.name != ruleName.value ||
|
||||
snapshot.author != authorName.value ||
|
||||
snapshot.scopes != scopes.toList() ||
|
||||
snapshot.events != snapshotEvents() ||
|
||||
snapshot.scopeType != currentScopeType
|
||||
}
|
||||
}
|
||||
var deleteConfirmation by remember { mutableStateOf(false) }
|
||||
@@ -426,6 +455,12 @@ class EditRule : Routes.Route() {
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
if (currentRuleId != null) {
|
||||
IconButton(onClick = { deleteConfirmation = true }) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
IconButton(onClick = {
|
||||
if (events.isEmpty()) {
|
||||
showEventsEmptyDialog = true
|
||||
@@ -451,11 +486,6 @@ class EditRule : Routes.Route() {
|
||||
context.database.setRuleTrackerScopes(ruleId, currentScopeType, scopes)
|
||||
routes.navController.popBackStack()
|
||||
}) { Icon(Icons.Filled.Save, contentDescription = translation["save_button_description"], tint = Color.White) }
|
||||
if (currentRuleId != null) {
|
||||
IconButton(onClick = { deleteConfirmation = true }) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,9 +496,11 @@ class EditRule : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.padding(padding)
|
||||
) {
|
||||
val contentBottomPadding = routes.bottomPadding + 12.dp
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = contentBottomPadding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
@@ -477,6 +509,7 @@ class EditRule : Routes.Route() {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
val textFieldShape = RoundedCornerShape(16.dp)
|
||||
Text(
|
||||
translation["general_section_title"],
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -488,8 +521,11 @@ class EditRule : Routes.Route() {
|
||||
value = ruleName.value,
|
||||
onValueChange = { ruleName.value = it },
|
||||
label = { Text(translation["rule_name_label"], color = PurrfectPalette.textSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp),
|
||||
singleLine = true,
|
||||
shape = textFieldShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
|
||||
@@ -506,8 +542,11 @@ class EditRule : Routes.Route() {
|
||||
value = authorName.value,
|
||||
onValueChange = { authorName.value = it },
|
||||
label = { Text(translation["author_name_label"], color = PurrfectPalette.textSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 2.dp),
|
||||
singleLine = true,
|
||||
shape = textFieldShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.04f),
|
||||
@@ -640,12 +679,21 @@ class EditRule : Routes.Route() {
|
||||
shape = selectorShape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.38f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(selectorBrush, selectorShape)
|
||||
.clip(selectorShape)
|
||||
.background(selectorBrush)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
@@ -773,7 +821,6 @@ class EditRule : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
|
||||
Text(translation["manage_repos_description"] ?: context.translation["manager.routes.manage_friend_tracker_repos"])
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -176,7 +176,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
|
||||
|
||||
@@ -115,7 +115,7 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -78,7 +78,7 @@ class FriendTrackerConfigImportScreen : Routes.Route() {
|
||||
routes.onRuleImported?.invoke()
|
||||
routes.navController.popBackStack()
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}) {
|
||||
Text(translation["confirm_button"])
|
||||
|
||||
@@ -18,20 +18,14 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AutoGraph
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Rule
|
||||
import androidx.compose.material.icons.automirrored.filled.Rule
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Store
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
@@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
private val titles by lazy {
|
||||
internal val titles by lazy {
|
||||
listOf(
|
||||
translation["rules_tab"],
|
||||
translation["logs_tab"]
|
||||
)
|
||||
}
|
||||
private var currentPage by mutableIntStateOf(0)
|
||||
private lateinit var logDeleteAction : () -> Unit
|
||||
private lateinit var exportAction : () -> Unit
|
||||
internal var currentPage by mutableIntStateOf(0)
|
||||
internal lateinit var logDeleteAction : () -> Unit
|
||||
internal lateinit var exportAction : () -> Unit
|
||||
|
||||
@Composable
|
||||
private fun TrackerIconButton(
|
||||
internal fun TrackerIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerActionButton(
|
||||
internal fun TrackerActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPillButton(
|
||||
internal fun TrackerPillButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -248,7 +244,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
context.longToast(
|
||||
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
@@ -345,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigRulesTab() {
|
||||
internal fun ConfigRulesTab() {
|
||||
val updateRules = rememberAsyncUpdateDispatcher()
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
@@ -466,7 +464,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.Rule, contentDescription = null, tint = Color.White)
|
||||
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
@@ -566,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
@Composable
|
||||
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
|
||||
currentPage = pagerState.currentPage
|
||||
@@ -591,7 +589,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
context.longToast(
|
||||
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -792,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@FriendTrackerManagerRoot.FriendTrackerScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -11,26 +11,24 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.SaveAlt
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import java.util.Date
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
@@ -328,36 +326,50 @@ fun LogsTab(
|
||||
var dropDownExpanded by remember { mutableStateOf(false) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(selectionExpanded.value) {
|
||||
if (!selectionExpanded.value) {
|
||||
dropDownExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showDatePicker) {
|
||||
if (showDatePicker) {
|
||||
sinceDatePickerState.displayMode = DisplayMode.Picker
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
Dialog(onDismissRequest = { showDatePicker = false }) {
|
||||
val dialogShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = dialogShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
shadowElevation = 16.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, dialogShape)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDatePicker = false },
|
||||
title = "",
|
||||
text = "",
|
||||
icon = Icons.Default.DateRange,
|
||||
confirmButtonText = context.translation["button.ok"],
|
||||
onConfirm = { showDatePicker = false },
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = {
|
||||
showDatePicker = false
|
||||
sinceDatePickerState.selectedDateMillis = null
|
||||
},
|
||||
customContent = {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 6.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
translation["pick_a_date_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
DatePicker(
|
||||
state = sinceDatePickerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.White.copy(alpha = 0.02f), RoundedCornerShape(18.dp)),
|
||||
.widthIn(max = 360.dp)
|
||||
.padding(6.dp)
|
||||
.background(PurrfectPalette.cardOverlayColor, RoundedCornerShape(14.dp)),
|
||||
title = null,
|
||||
headline = null,
|
||||
showModeToggle = false,
|
||||
colors = DatePickerDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
containerColor = PurrfectPalette.cardOverlayColor,
|
||||
titleContentColor = Color.White,
|
||||
headlineContentColor = Color.White,
|
||||
weekdayContentColor = Color.White.copy(alpha = 0.9f),
|
||||
@@ -368,45 +380,35 @@ fun LogsTab(
|
||||
todayDateBorderColor = PurrfectPalette.glowSecondary,
|
||||
dayContentColor = Color.White.copy(alpha = 0.85f),
|
||||
disabledDayContentColor = Color.White.copy(alpha = 0.35f),
|
||||
yearContentColor = Color.White,
|
||||
currentYearContentColor = PurrfectPalette.glowSecondary,
|
||||
selectedYearContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
selectedYearContentColor = Color.Black,
|
||||
dividerColor = Color.White.copy(alpha = 0.14f),
|
||||
navigationContentColor = Color.White
|
||||
navigationContentColor = Color.White,
|
||||
dateTextFieldColors = TextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
disabledTextColor = Color.White.copy(alpha = 0.6f),
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
|
||||
disabledContainerColor = Color.White.copy(alpha = 0.04f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
Surface(
|
||||
onClick = {
|
||||
showDatePicker = false
|
||||
sinceDatePickerState.selectedDateMillis = null
|
||||
},
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
context.translation["button.cancel"],
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = { showDatePicker = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Text(
|
||||
context.translation["button.ok"],
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
opaque = true,
|
||||
showCloseButton = false,
|
||||
showIcon = false,
|
||||
showTitle = false
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
@@ -443,27 +445,33 @@ fun LogsTab(
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = dropDownExpanded,
|
||||
onExpandedChange = { dropDownExpanded = it },
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.wrapContentWidth()
|
||||
) {
|
||||
Surface(
|
||||
onClick = { dropDownExpanded = true },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(12.dp)),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White.copy(alpha = 0.06f)
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Text(
|
||||
filterType.name,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(filterType.name, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropDownExpanded)
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenu(
|
||||
expanded = dropDownExpanded,
|
||||
onDismissRequest = { dropDownExpanded = false },
|
||||
containerColor = Color(0xFF101220),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
modifier = Modifier.wrapContentWidth()
|
||||
) {
|
||||
FriendTrackerManagerRoot.FilterType.entries.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
@@ -489,7 +497,6 @@ fun LogsTab(
|
||||
checked = reverseSortOrder,
|
||||
onCheckedChange = {
|
||||
reverseSortOrder = it
|
||||
selectionExpanded.value = false
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -99,7 +100,14 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showAddDialog = true },
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
contentColor = Color.White,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = 0.dp,
|
||||
pressedElevation = 0.dp,
|
||||
focusedElevation = 0.dp,
|
||||
hoveredElevation = 0.dp
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -237,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -274,7 +282,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -16,10 +16,14 @@ class TrackerConfigParser(private val context: Routes.Route) {
|
||||
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
|
||||
val featureMap = mutableMapOf<String, MutableList<ImportedFeature>>()
|
||||
val exportedData = context.context.gson.fromJson(configJson, ExportedTrackerData::class.java)
|
||||
val authorLabel = context.translation["tracker_author_label"]
|
||||
val enabledLabel = context.translation["tracker_enabled_label"]
|
||||
val enabledValue = context.translation["tracker_enabled_value"]
|
||||
val disabledValue = context.translation["tracker_disabled_value"]
|
||||
exportedData.rules.forEach { rule ->
|
||||
val features = mutableListOf<ImportedFeature>()
|
||||
features.add(ImportedFeature(rule.name, "Author", "author", rule.author ?: "Unknown", 0))
|
||||
features.add(ImportedFeature(rule.name, "Enabled", "enabled", rule.enabled, 0))
|
||||
features.add(ImportedFeature(rule.name, authorLabel, "author", rule.author ?: context.context.translation["common.unknown"], 0))
|
||||
features.add(ImportedFeature(rule.name, enabledLabel, "enabled", if (rule.enabled) enabledValue else disabledValue, 0))
|
||||
rule.events?.forEach { event ->
|
||||
features.add(ImportedFeature(rule.name, context.context.translation["tracker_events.${event.eventType}"], event.eventType, event.actions.joinToString(", ") { context.context.translation["tracker_actions.${it.key}"] }, 1))
|
||||
}
|
||||
@@ -30,7 +34,7 @@ class TrackerConfigParser(private val context: Routes.Route) {
|
||||
|
||||
fun parseValue(featureKey: String, value: Any): Any {
|
||||
return when (value) {
|
||||
is Boolean -> if (value) "Enabled" else "Disabled"
|
||||
is Boolean -> if (value) context.translation["tracker_enabled_value"] else context.translation["tracker_disabled_value"]
|
||||
is JSONArray -> {
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until value.length()) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
/**
|
||||
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
|
||||
* Provides more nuanced feedback than standard Compose haptics.
|
||||
*/
|
||||
object AphelionHaptics {
|
||||
|
||||
/**
|
||||
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
|
||||
*/
|
||||
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
val vibrator = vibratorManager?.defaultVibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
// Fallback for older APIs
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a "soft" impact feel, good for glass interactions.
|
||||
*/
|
||||
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||
} else {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
|
||||
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
private const val REVEAL_DURATION_MS = 3200
|
||||
private const val WAVE_BAND_WIDTH_PX = 300f
|
||||
private const val BLUR_ZONE_PX = 120f
|
||||
private const val BLUR_RADIUS = 30f
|
||||
private const val FADE_ZONE_PX = 80f
|
||||
|
||||
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
|
||||
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
|
||||
|
||||
@Composable
|
||||
fun CircularRevealOverlay(
|
||||
context: RemoteSideContext,
|
||||
request: ThemeRevealRequest,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
// Safety check: if bitmap was recycled or is null, skip.
|
||||
val bitmap = request.oldThemeBitmap ?: run {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
if (bitmap.isRecycled) {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// Ensure the reveal is cleared even if navigation happens mid-animation
|
||||
DisposableEffect(request.id) {
|
||||
onDispose { onComplete() }
|
||||
}
|
||||
|
||||
val maxRadius = remember(configuration) {
|
||||
with(density) {
|
||||
val w = configuration.screenWidthDp.dp.toPx()
|
||||
val h = configuration.screenHeightDp.dp.toPx()
|
||||
sqrt(w * w + h * h)
|
||||
}
|
||||
}
|
||||
|
||||
val animatedRadius = remember(request.id) { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(request.id) {
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
animatedRadius.animateTo(
|
||||
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
|
||||
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
|
||||
val timeValue by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 10f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
|
||||
label = "wave_time_value"
|
||||
)
|
||||
|
||||
val runtimeShader = remember(bitmap) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
|
||||
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
val shaderPaint = remember(runtimeShader, bitmap) {
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radius = animatedRadius.value
|
||||
val center = request.originCenter
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
|
||||
runtimeShader.setFloatUniform("revealRadius", radius)
|
||||
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
|
||||
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
|
||||
runtimeShader.setFloatUniform("time", timeValue)
|
||||
runtimeShader.setFloatUniform("uProgress", progress)
|
||||
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
|
||||
} else {
|
||||
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
private fun drawWithBlurReveal(
|
||||
canvas: android.graphics.Canvas,
|
||||
bitmap: android.graphics.Bitmap,
|
||||
radius: Float,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
canvasWidth: Float,
|
||||
canvasHeight: Float
|
||||
) {
|
||||
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
|
||||
val holePath = android.graphics.Path().apply {
|
||||
fillType = android.graphics.Path.FillType.EVEN_ODD
|
||||
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
|
||||
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.clipPath(holePath)
|
||||
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
)
|
||||
canvas.restore()
|
||||
|
||||
val ringPath = android.graphics.Path().apply {
|
||||
fillType = android.graphics.Path.FillType.EVEN_ODD
|
||||
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
|
||||
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
|
||||
}
|
||||
|
||||
val renderNode = android.graphics.RenderNode("blurRing").apply {
|
||||
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
|
||||
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
|
||||
}
|
||||
val nodeCanvas = renderNode.beginRecording()
|
||||
nodeCanvas.save()
|
||||
nodeCanvas.clipPath(ringPath)
|
||||
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
|
||||
nodeCanvas.restore()
|
||||
renderNode.endRecording()
|
||||
canvas.drawRenderNode(renderNode)
|
||||
|
||||
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = android.graphics.RadialGradient(
|
||||
centerX, centerY, radius,
|
||||
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
|
||||
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.clipPath(ringPath)
|
||||
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
private fun drawWithClipFade(
|
||||
canvas: android.graphics.Canvas,
|
||||
bitmap: android.graphics.Bitmap,
|
||||
radius: Float,
|
||||
centerX: Float,
|
||||
centerY: Float,
|
||||
canvasWidth: Float,
|
||||
canvasHeight: Float
|
||||
) {
|
||||
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
|
||||
val holePath = android.graphics.Path().apply {
|
||||
fillType = android.graphics.Path.FillType.EVEN_ODD
|
||||
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
|
||||
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.clipPath(holePath)
|
||||
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
}
|
||||
)
|
||||
canvas.restore()
|
||||
|
||||
val ringPath = android.graphics.Path().apply {
|
||||
fillType = android.graphics.Path.FillType.EVEN_ODD
|
||||
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
|
||||
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
|
||||
}
|
||||
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = android.graphics.RadialGradient(
|
||||
centerX, centerY, radius,
|
||||
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
|
||||
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
|
||||
Shader.TileMode.CLAMP
|
||||
)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.clipPath(ringPath)
|
||||
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
|
||||
canvas.restore()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
/**
|
||||
* Carries all data needed to execute one theme reveal transition.
|
||||
*/
|
||||
data class ThemeRevealRequest(
|
||||
val newThemeId: String,
|
||||
val originCenter: Offset,
|
||||
val oldThemeBitmap: android.graphics.Bitmap?,
|
||||
val id: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Observable state that lives on the [Navigation] instance.
|
||||
* Optimized for stability during rapid toggle events.
|
||||
*/
|
||||
class ThemeRevealState {
|
||||
|
||||
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Requests a new theme reveal animation.
|
||||
* Always starts a new reveal immediately, even if one is already in progress.
|
||||
*/
|
||||
fun requestReveal(
|
||||
newThemeId: String,
|
||||
originCenter: Offset,
|
||||
bitmap: android.graphics.Bitmap?
|
||||
) {
|
||||
// Clean up the old one first to prevent memory leaks and "dead periods"
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
|
||||
// Immediately update with the new request ID to force a fresh animation
|
||||
pendingReveal = ThemeRevealRequest(
|
||||
newThemeId = newThemeId,
|
||||
originCenter = originCenter,
|
||||
oldThemeBitmap = bitmap,
|
||||
id = System.currentTimeMillis() // Unique ID ensures fresh start
|
||||
)
|
||||
}
|
||||
|
||||
/** Called by the overlay composable once the animation has fully completed. */
|
||||
fun clearReveal() {
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
pendingReveal = null
|
||||
|
||||
// Manual memory management for the heavy screenshot bitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
/**
|
||||
* Finalized "Perfect Optic" AGSL Shader.
|
||||
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
|
||||
*/
|
||||
object WaveEdgeShader {
|
||||
|
||||
const val AGSL = """
|
||||
uniform shader content;
|
||||
uniform float revealRadius;
|
||||
uniform float2 revealCenter;
|
||||
uniform float bandWidth;
|
||||
uniform float time;
|
||||
uniform float uProgress;
|
||||
|
||||
float hash(float2 p) {
|
||||
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float valueNoise(float2 p) {
|
||||
float2 i = floor(p);
|
||||
float2 f = fract(p);
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
half4 main(float2 pos) {
|
||||
float d = distance(pos, revealCenter);
|
||||
|
||||
float energy = (1.0 - uProgress);
|
||||
// Slower, more majestic large noise
|
||||
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
|
||||
float totalNoise = noiseLarge * energy;
|
||||
|
||||
float dist = d - (revealRadius + totalNoise);
|
||||
|
||||
if (dist > 0.0) {
|
||||
return content.eval(pos);
|
||||
}
|
||||
|
||||
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
|
||||
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
|
||||
float effectStart = revealRadius - dynamicBand;
|
||||
|
||||
if (dist < -dynamicBand) {
|
||||
return half4(0.0);
|
||||
}
|
||||
|
||||
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
|
||||
|
||||
// Asymmetric crest: Sharp start, long slow tail
|
||||
float waveShape = pow(bandProgress, 2.0);
|
||||
|
||||
float2 dir = normalize(pos - revealCenter + 0.001);
|
||||
|
||||
// Refraction: Impactful but clear
|
||||
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
|
||||
float2 refractedPos = pos + dir * refractionAmt;
|
||||
|
||||
// Chromatic Aberration: Deep prism split
|
||||
float aberration = waveShape * (35.0 * energy + 10.0);
|
||||
half r = content.eval(refractedPos + dir * aberration).r;
|
||||
half g = content.eval(refractedPos).g;
|
||||
half b = content.eval(refractedPos - dir * aberration).b;
|
||||
|
||||
// Sharp highlight at the front edge
|
||||
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
|
||||
|
||||
// Leading edge softening (minor)
|
||||
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
|
||||
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
|
||||
|
||||
return half4(
|
||||
r + half(highlight),
|
||||
g + half(highlight),
|
||||
b + half(highlight),
|
||||
half(alpha)
|
||||
);
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -78,6 +78,7 @@ class RemoteOverlay(
|
||||
fun close() {
|
||||
if (!::dialog.isInitialized || !dialog.isShowing) return
|
||||
dismissCallback = null
|
||||
context.sharedPreferences.edit().putBoolean("overlay_active", false).apply()
|
||||
context.androidContext.mainExecutor.execute {
|
||||
dialog.dismiss()
|
||||
}
|
||||
@@ -92,6 +93,7 @@ class RemoteOverlay(
|
||||
return
|
||||
}
|
||||
|
||||
context.sharedPreferences.edit().putBoolean("overlay_active", true).apply()
|
||||
context.androidContext.mainExecutor.execute {
|
||||
dialog = object: Dialog(context.androidContext, R.style.FullscreenOverlayDialog) {
|
||||
override fun dismiss() {
|
||||
@@ -99,6 +101,9 @@ class RemoteOverlay(
|
||||
if (it()) return
|
||||
}
|
||||
super.dismiss()
|
||||
this@RemoteOverlay.context.sharedPreferences.edit()
|
||||
.putBoolean("overlay_active", false)
|
||||
.apply()
|
||||
this@RemoteOverlay.context.config.writeConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package me.eternal.purrfectsnap.ui.setup
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -56,12 +57,14 @@ import androidx.compose.material.icons.filled.Flag
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -76,6 +79,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -89,6 +93,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.InstallModeScreen
|
||||
@@ -97,8 +102,10 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.MappingsScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.PermissionsScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private data class SetupStepMeta(
|
||||
val route: String,
|
||||
@@ -125,14 +132,19 @@ class SetupActivity : ComponentActivity() {
|
||||
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
|
||||
val persistedRoute = setupPrefs.getString("setup_current_route", null)
|
||||
val persistedSkipPatch = setupPrefs.getBoolean("setup_skip_patch", false)
|
||||
val persistedInstallMode = setupPrefs.getString("setup_install_mode", null)
|
||||
val skipPatchChoice = mutableStateOf(persistedSkipPatch)
|
||||
val installModeChoice = mutableStateOf(
|
||||
runCatching { persistedInstallMode?.let { InstallMode.valueOf(it) } }.getOrNull()
|
||||
)
|
||||
|
||||
fun persistProgress(route: String, skipPatch: Boolean, inProgress: Boolean = true) {
|
||||
fun persistProgress(route: String, skipPatch: Boolean, installMode: InstallMode?, inProgress: Boolean = true) {
|
||||
if (!isFirstRunFlow) return
|
||||
setupPrefs.edit()
|
||||
.putBoolean("setup_in_progress", inProgress)
|
||||
.putString("setup_current_route", route)
|
||||
.putBoolean("setup_skip_patch", skipPatch)
|
||||
.putString("setup_install_mode", installMode?.name)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -141,6 +153,7 @@ class SetupActivity : ComponentActivity() {
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -148,11 +161,19 @@ class SetupActivity : ComponentActivity() {
|
||||
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
|
||||
add(PickLanguageScreen().apply { route = "language" })
|
||||
if (isFirstRunFlow) {
|
||||
add(InstallModeScreen { mode ->
|
||||
skipPatchChoice.value = mode == InstallMode.ROOT
|
||||
}.apply { route = "installMode" })
|
||||
add(InstallModeScreen(
|
||||
onModeChosen = { mode ->
|
||||
installModeChoice.value = mode
|
||||
skipPatchChoice.value = false
|
||||
},
|
||||
onSkipAutoSetup = {
|
||||
skipPatchChoice.value = true
|
||||
installModeChoice.value = null
|
||||
}
|
||||
).apply { route = "installMode" })
|
||||
}
|
||||
if (isFirstRunFlow) {
|
||||
add(RootInstallSnapchatScreen().apply { route = "rootInstallSnapchat" })
|
||||
add(PatchSnapchatScreen().apply { route = "patchSnapchat" })
|
||||
}
|
||||
}
|
||||
@@ -173,31 +194,73 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
requiredScreens.forEach { screen ->
|
||||
screen.context = setupContext
|
||||
screen.isFirstRunFlow = isFirstRunFlow
|
||||
screen.init()
|
||||
}
|
||||
|
||||
if (!isFirstRunFlow) {
|
||||
clearProgress()
|
||||
skipPatchChoice.value = false
|
||||
}
|
||||
if (!isFirstRunFlow) {
|
||||
clearProgress()
|
||||
skipPatchChoice.value = false
|
||||
installModeChoice.value = null
|
||||
}
|
||||
|
||||
setContent {
|
||||
val context = LocalContext.current
|
||||
val translation = setupContext.translation
|
||||
val navController = rememberNavController()
|
||||
var canGoNext by remember { mutableStateOf(false) }
|
||||
var lastRoute by rememberSaveable { mutableStateOf("") }
|
||||
var currentRoute by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when {
|
||||
requiredScreens.first().route == "language" -> requiredScreens.first().route
|
||||
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } } != null -> persistedRoute
|
||||
else -> requiredScreens.first().route
|
||||
}
|
||||
persistedRoute?.takeIf { route -> requiredScreens.any { it.route == route } }
|
||||
?: requiredScreens.first().route
|
||||
)
|
||||
}
|
||||
val skipPatch by rememberSaveable { skipPatchChoice }
|
||||
val visibleScreens = remember(skipPatch) {
|
||||
requiredScreens.filterNot { skipPatch && it is PatchSnapchatScreen }
|
||||
val installMode by installModeChoice
|
||||
val shouldShowAbiWarning = remember {
|
||||
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
|
||||
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
|
||||
val appIsArm64 = libDir.contains("arm64")
|
||||
deviceIsArm64 && !appIsArm64
|
||||
}
|
||||
val stepMeta = remember(skipPatch) { visibleScreens.map { it.meta(setupContext) } }
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val visibleScreens = remember(skipPatch, installMode) {
|
||||
requiredScreens.filterNot { screen ->
|
||||
if (skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
|
||||
return@filterNot true
|
||||
}
|
||||
if (installMode == null && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
|
||||
return@filterNot true
|
||||
}
|
||||
if (installMode == InstallMode.ROOT && screen is PatchSnapchatScreen) {
|
||||
return@filterNot true
|
||||
}
|
||||
if (installMode == InstallMode.NON_ROOT && screen is RootInstallSnapchatScreen) {
|
||||
return@filterNot true
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
val stepMeta = remember(skipPatch, installMode) { visibleScreens.map { it.meta(setupContext) } }
|
||||
val currentStepIndex = visibleScreens.indexOfFirst { it.route == currentRoute }.let {
|
||||
if (it == -1) 0 else it
|
||||
}
|
||||
@@ -218,15 +281,18 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentRoute, skipPatch) {
|
||||
persistProgress(currentRoute, skipPatch, true)
|
||||
LaunchedEffect(currentRoute, skipPatch, installMode) {
|
||||
persistProgress(currentRoute, skipPatch, installMode, true)
|
||||
if (navController.currentDestination?.route != currentRoute) {
|
||||
navController.navigate(currentRoute) {
|
||||
popUpTo(requiredScreens.first().route) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
canGoNext = false
|
||||
if (lastRoute != currentRoute) {
|
||||
canGoNext = false
|
||||
lastRoute = currentRoute
|
||||
}
|
||||
}
|
||||
|
||||
fun nextScreen() {
|
||||
@@ -249,10 +315,25 @@ class SetupActivity : ComponentActivity() {
|
||||
AppMaterialTheme {
|
||||
val view = LocalView.current
|
||||
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
var showImportantDialog by rememberSaveable {
|
||||
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
|
||||
}
|
||||
var importantTimeout by remember { mutableIntStateOf(5) }
|
||||
LaunchedEffect(showImportantDialog) {
|
||||
if (showImportantDialog) {
|
||||
importantTimeout = 5
|
||||
while (importantTimeout > 0) {
|
||||
delay(1000)
|
||||
importantTimeout--
|
||||
}
|
||||
}
|
||||
}
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@Suppress("DEPRECATION")
|
||||
window.statusBarColor = Color.Transparent.toArgb()
|
||||
@Suppress("DEPRECATION")
|
||||
window.navigationBarColor = Color.Transparent.toArgb()
|
||||
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
@@ -263,6 +344,44 @@ class SetupActivity : ComponentActivity() {
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
if (showImportantDialog) {
|
||||
val confirmLabel = if (importantTimeout > 0) {
|
||||
translation.format(
|
||||
"setup.activity.important_confirm_timeout",
|
||||
"seconds" to importantTimeout.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.activity.important_confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
title = translation["setup.activity.important_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = {
|
||||
if (importantTimeout == 0) {
|
||||
showImportantDialog = false
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
confirmEnabled = importantTimeout == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Text(
|
||||
text = translation["setup.activity.important_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
},
|
||||
opaque = true
|
||||
)
|
||||
}
|
||||
SetupAuroraBackground()
|
||||
SetupTopBar()
|
||||
val bottomPadding = 118.dp + navBarPadding
|
||||
@@ -360,43 +479,50 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
|
||||
return when (this) {
|
||||
is PickLanguageScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.dialogs.select_language"] ?: "Choose your language",
|
||||
subtitle = "Tune PurrfectSnap to speak your voice before anything else.",
|
||||
title = translation["setup.dialogs.select_language"],
|
||||
subtitle = translation["setup.activity.language_subtitle"],
|
||||
icon = Icons.Filled.Language
|
||||
)
|
||||
|
||||
is InstallModeScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Choose your device",
|
||||
subtitle = "Pick the path that matches how you'll install PurrfectSnap.",
|
||||
title = translation["setup.activity.install_mode_title"],
|
||||
subtitle = translation["setup.activity.install_mode_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser
|
||||
)
|
||||
|
||||
is PermissionsScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.permissions.dialog"] ?: "Essential permissions",
|
||||
subtitle = "Grant the essentials so overlays, downloads, and alerts stay reliable.",
|
||||
title = translation["setup.permissions.dialog"],
|
||||
subtitle = translation["setup.activity.permissions_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser
|
||||
)
|
||||
|
||||
is PatchSnapchatScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Auto Patcher",
|
||||
subtitle = "Streamlined download, patch, and install with a single flow.",
|
||||
title = translation["setup.activity.patch_title"],
|
||||
subtitle = translation["setup.activity.patch_subtitle"],
|
||||
icon = Icons.Filled.Download
|
||||
)
|
||||
|
||||
is RootInstallSnapchatScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.activity.root_install_title"],
|
||||
subtitle = translation["setup.activity.root_install_subtitle"],
|
||||
icon = Icons.Filled.Download
|
||||
)
|
||||
|
||||
is SaveFolderScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.dialogs.save_folder"] ?: "Where should we save?",
|
||||
subtitle = "Pick your personal vault so snaps land exactly where you expect.",
|
||||
title = translation["setup.dialogs.save_folder"],
|
||||
subtitle = translation["setup.activity.save_folder_subtitle"],
|
||||
icon = Icons.Filled.Folder
|
||||
)
|
||||
|
||||
is MappingsScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.mappings.dialog"] ?: "Mapping your Snapchat",
|
||||
subtitle = "We calibrate everything to your install so the magic works flawlessly.",
|
||||
title = translation["setup.mappings.dialog"],
|
||||
subtitle = translation["setup.activity.mappings_subtitle"],
|
||||
icon = Icons.Filled.AutoAwesome
|
||||
)
|
||||
|
||||
@@ -505,6 +631,7 @@ private fun SetupHeader(
|
||||
currentIndex: Int,
|
||||
total: Int
|
||||
) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -530,7 +657,11 @@ private fun SetupHeader(
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Step ${currentIndex + 1} of $total",
|
||||
text = translation.format(
|
||||
"setup.activity.step_counter",
|
||||
"current" to (currentIndex + 1).toString(),
|
||||
"total" to total.toString()
|
||||
),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
@@ -605,6 +736,7 @@ private fun StepBadgesRow(steps: List<SetupStepMeta>, currentStep: Int) {
|
||||
|
||||
@Composable
|
||||
private fun StepBadge(step: SetupStepMeta, state: StepState) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
val baseColor = when (state) {
|
||||
StepState.COMPLETE -> PurrfectPalette.glowSecondary
|
||||
StepState.ACTIVE -> PurrfectPalette.glowPrimary
|
||||
@@ -656,9 +788,9 @@ private fun StepBadge(step: SetupStepMeta, state: StepState) {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val hint = when (state) {
|
||||
StepState.COMPLETE -> "Checked off"
|
||||
StepState.ACTIVE -> "In progress"
|
||||
StepState.UPCOMING -> "Ready next"
|
||||
StepState.COMPLETE -> translation["setup.activity.step_complete"]
|
||||
StepState.ACTIVE -> translation["setup.activity.step_active"]
|
||||
StepState.UPCOMING -> translation["setup.activity.step_upcoming"]
|
||||
}
|
||||
Text(
|
||||
text = hint,
|
||||
@@ -750,6 +882,7 @@ private fun NextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.6f, label = "NextButtonAlpha")
|
||||
val gradient = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -785,7 +918,11 @@ private fun NextButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isFinalStep) "Finish setup" else "Continue",
|
||||
text = if (isFinalStep) {
|
||||
translation["setup.activity.finish_button"]
|
||||
} else {
|
||||
translation["setup.activity.continue_button"]
|
||||
},
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp
|
||||
|
||||
@@ -28,6 +28,7 @@ abstract class SetupScreen {
|
||||
lateinit var allowNext: (canGoNext: Boolean) -> Unit
|
||||
lateinit var goNext: () -> Unit
|
||||
lateinit var route: String
|
||||
var isFirstRunFlow: Boolean = false
|
||||
|
||||
@Composable
|
||||
fun DialogText(text: String, modifier: Modifier = Modifier) {
|
||||
|
||||
@@ -9,19 +9,26 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -32,9 +39,16 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
@@ -42,31 +56,150 @@ import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
enum class InstallMode { ROOT, NON_ROOT }
|
||||
|
||||
class InstallModeScreen(
|
||||
private val onModeChosen: (InstallMode) -> Unit
|
||||
private val onModeChosen: (InstallMode) -> Unit,
|
||||
private val onSkipAutoSetup: () -> Unit
|
||||
) : SetupScreen() {
|
||||
private var selectedMode: InstallMode? = null
|
||||
private var skipAutoSetup = false
|
||||
|
||||
override fun init() {
|
||||
selectedMode = null
|
||||
skipAutoSetup = false
|
||||
}
|
||||
|
||||
override fun onLeave() {
|
||||
selectedMode?.let(onModeChosen)
|
||||
if (skipAutoSetup) return
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
var choice by remember { mutableStateOf(selectedMode) }
|
||||
var skipSelected by remember { mutableStateOf(skipAutoSetup) }
|
||||
var showGuides by remember { mutableStateOf(true) }
|
||||
var timeout by remember { mutableIntStateOf(15) }
|
||||
|
||||
LaunchedEffect(choice) {
|
||||
LaunchedEffect(choice, skipSelected) {
|
||||
selectedMode = choice
|
||||
allowNext(choice != null)
|
||||
skipAutoSetup = skipSelected
|
||||
allowNext(choice != null || skipSelected)
|
||||
if (!skipSelected) {
|
||||
choice?.let(onModeChosen)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showGuides) {
|
||||
if (showGuides) {
|
||||
timeout = 15
|
||||
while (timeout > 0) {
|
||||
delay(1000)
|
||||
timeout--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showGuides) {
|
||||
val confirmLabel = if (timeout > 0) {
|
||||
context.translation.format("setup.install_mode.confirm_timeout", "seconds" to timeout.toString())
|
||||
} else {
|
||||
context.translation["setup.install_mode.confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (timeout == 0) showGuides = false },
|
||||
title = context.translation["setup.install_mode.notice_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = { if (timeout == 0) showGuides = false },
|
||||
confirmEnabled = timeout == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_intro"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_non_root_title"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_non_root_body"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_root_title"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_root_body"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.notice_issues_hint"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.White)) {
|
||||
append(context.translation["setup.install_mode.notice_note_prefix"])
|
||||
}
|
||||
append(context.translation["setup.install_mode.notice_note_body"])
|
||||
},
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Choose your device",
|
||||
subtitle = "If you don't know, select Non-rooted device and proceed.",
|
||||
title = context.translation["setup.install_mode.step_title"],
|
||||
subtitle = context.translation["setup.install_mode.step_subtitle"],
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Column(
|
||||
@@ -74,8 +207,8 @@ class InstallModeScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ModeOption(
|
||||
title = "Rooted device",
|
||||
subtitle = "Use Lsposed and skip auto patching.",
|
||||
title = context.translation["setup.install_mode.root_option_title"],
|
||||
subtitle = context.translation["setup.install_mode.root_option_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -84,11 +217,14 @@ class InstallModeScreen(
|
||||
)
|
||||
),
|
||||
selected = choice == InstallMode.ROOT,
|
||||
onClick = { choice = InstallMode.ROOT }
|
||||
onClick = {
|
||||
choice = InstallMode.ROOT
|
||||
skipSelected = false
|
||||
}
|
||||
)
|
||||
ModeOption(
|
||||
title = "Non-rooted device",
|
||||
subtitle = "Use included auto patcher to install patched Snapchat.",
|
||||
title = context.translation["setup.install_mode.non_root_option_title"],
|
||||
subtitle = context.translation["setup.install_mode.non_root_option_subtitle"],
|
||||
icon = Icons.Filled.Shield,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -97,9 +233,54 @@ class InstallModeScreen(
|
||||
)
|
||||
),
|
||||
selected = choice == InstallMode.NON_ROOT,
|
||||
onClick = { choice = InstallMode.NON_ROOT }
|
||||
onClick = {
|
||||
choice = InstallMode.NON_ROOT
|
||||
skipSelected = false
|
||||
}
|
||||
)
|
||||
}
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scaleOnPress(interactionSource)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
skipSelected = true
|
||||
choice = null
|
||||
onSkipAutoSetup()
|
||||
goNext()
|
||||
},
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["setup.install_mode.skip_auto_setup"],
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
tint = PurrfectPalette.glowSecondary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,21 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -26,11 +34,15 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
@@ -40,13 +52,24 @@ class MappingsScreen : SetupScreen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val translation = context.translation
|
||||
var infoText by remember { mutableStateOf(null as String?) }
|
||||
var isGenerating by remember { mutableStateOf(false) }
|
||||
var showCompletionNotice by remember { mutableStateOf(false) }
|
||||
var completionCountdown by remember { mutableIntStateOf(10) }
|
||||
|
||||
fun finishMappings() {
|
||||
if (isFirstRunFlow) {
|
||||
showCompletionNotice = true
|
||||
} else {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
|
||||
if (infoText != null) {
|
||||
fun dismiss() {
|
||||
infoText = null
|
||||
goNext()
|
||||
finishMappings()
|
||||
}
|
||||
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
@@ -64,26 +87,128 @@ class MappingsScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showCompletionNotice) {
|
||||
if (showCompletionNotice) {
|
||||
completionCountdown = 10
|
||||
while (completionCountdown > 0) {
|
||||
delay(1000)
|
||||
completionCountdown--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompletionNotice) {
|
||||
val confirmLabel = if (completionCountdown > 0) {
|
||||
translation.format(
|
||||
"setup.mappings.confirm_understand_timeout",
|
||||
"seconds" to completionCountdown.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.mappings.confirm_understand"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
title = translation["setup.mappings.notice_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
confirmEnabled = completionCountdown == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_intro"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_1"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_2"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_step_3"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_rooted_title"],
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.mappings.notice_rooted_body"],
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (isGenerating) return@launch
|
||||
isGenerating = true
|
||||
runCatching {
|
||||
if (context.installationSummary.snapchatInfo == null) {
|
||||
context.mappings.init(context.androidContext)
|
||||
if (context.mappings.getSnapchatPackageInfo() == null) {
|
||||
throw Exception(context.translation["setup.mappings.generate_failure_no_snapchat"])
|
||||
}
|
||||
val warnings = context.mappings.refresh()
|
||||
|
||||
if (warnings.isNotEmpty()) {
|
||||
isGenerating = false
|
||||
infoText = "${warnings.size} warning(s) occurred while generating mappings:\n\n${warnings.joinToString("\n")}".also {
|
||||
infoText = translation.format(
|
||||
"setup.mappings.warnings_info",
|
||||
"count" to warnings.size.toString(),
|
||||
"warnings" to warnings.joinToString("\n")
|
||||
).also {
|
||||
context.log.warn(it)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
goNext()
|
||||
finishMappings()
|
||||
}
|
||||
}.onFailure {
|
||||
isGenerating = false
|
||||
@@ -99,7 +224,7 @@ class MappingsScreen : SetupScreen() {
|
||||
subtitle = null
|
||||
)
|
||||
DialogText(
|
||||
text = "This only takes a moment. Keep the app open while magic happens!"
|
||||
text = translation["setup.mappings.progress_hint"]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,12 +20,16 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
@@ -52,8 +57,10 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
@@ -64,8 +71,10 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.setup.patch.LSPatch
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
@@ -85,7 +94,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val logs = remember { mutableStateListOf("Auto Patcher is ready.") }
|
||||
val translation = context.translation
|
||||
val logs = remember { mutableStateListOf(translation["setup.patch.ready_log"]) }
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var progress by remember { mutableFloatStateOf(-1f) }
|
||||
var patchedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -98,6 +110,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
var installRequested by rememberSaveable { mutableStateOf(false) }
|
||||
var installWatcher by remember { mutableStateOf<Job?>(null) }
|
||||
var downloadFinished by rememberSaveable { mutableStateOf(false) }
|
||||
var showIssuesDialog by remember { mutableStateOf(false) }
|
||||
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
@@ -112,7 +125,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
LaunchedEffect(installVerified) { allowNext(installVerified) }
|
||||
|
||||
fun pushLog(message: String) {
|
||||
if (logs.size > 16) logs.removeFirst()
|
||||
if (logs.size > 120) logs.removeAt(0)
|
||||
logs.add(message)
|
||||
}
|
||||
|
||||
@@ -135,7 +148,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
repeat(80) {
|
||||
if (isSnapchatInstalledAfter(patchStartedAt)) {
|
||||
installVerified = true
|
||||
pushLog("Snapchat install confirmed. You're cleared to continue.")
|
||||
pushLog(translation["setup.patch.install_confirmed_log"])
|
||||
return@launch
|
||||
}
|
||||
delay(1200)
|
||||
@@ -168,13 +181,24 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
|
||||
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
|
||||
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
|
||||
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.download_recommended_status",
|
||||
"version" to latestApk.tagName
|
||||
)
|
||||
)
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
|
||||
?: context.androidContext.cacheDir
|
||||
val cacheDir = listOfNotNull(
|
||||
context.activity?.externalCacheDir,
|
||||
context.androidContext.externalCacheDir,
|
||||
context.androidContext.cacheDir
|
||||
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
|
||||
val outputFile = File(cacheDir, latestApk.apkName)
|
||||
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
|
||||
return@withContext null
|
||||
}
|
||||
outputFile.outputStream().use { output ->
|
||||
response.body?.byteStream()?.use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
@@ -206,22 +230,27 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
downloadFinished = false
|
||||
patchStartedAt = System.currentTimeMillis()
|
||||
logs.clear()
|
||||
pushLog("Starting Auto Patcher for recommended Snapchat version.")
|
||||
pushLog(translation["setup.patch.starting_log"])
|
||||
runCatching {
|
||||
if (isSnapchatInstalled()) {
|
||||
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then start Auto Patcher again.")
|
||||
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
|
||||
pushStatus(translation["setup.patch.uninstall_prompt_status"])
|
||||
throw IllegalStateException(translation["setup.patch.uninstall_prompt_error"])
|
||||
}
|
||||
val modulePath = context.androidContext.packageManager.getPackageInfo(
|
||||
context.androidContext.packageName, 0
|
||||
).applicationInfo?.sourceDir ?: throw IllegalStateException("Module apk not found")
|
||||
pushStatus("Fetching recommended Snapchat APK...")
|
||||
).applicationInfo?.sourceDir ?: throw IllegalStateException(translation["setup.patch.module_apk_not_found_error"])
|
||||
pushStatus(translation["setup.patch.fetching_apk_status"])
|
||||
val downloaded = downloadSnapchatFromAutoPatchServer()
|
||||
?: throw IllegalStateException("Download failed")
|
||||
?: throw IllegalStateException(translation["setup.patch.download_failed_error"])
|
||||
downloadedApkPath = downloaded.absolutePath
|
||||
pushStatus("Download completed: ${downloaded.name}")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.download_completed_status",
|
||||
"fileName" to downloaded.name
|
||||
)
|
||||
)
|
||||
downloadFinished = true
|
||||
pushStatus("Starting patch powered by Jingmatrix Lspatch")
|
||||
pushStatus(translation["setup.patch.starting_patch_status"])
|
||||
val lsPatch = LSPatch(
|
||||
context.androidContext,
|
||||
mapOf(context.androidContext.packageName to File(modulePath)),
|
||||
@@ -230,12 +259,22 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
)
|
||||
val outputs = withContext(Dispatchers.IO) { lsPatch.patchSplits(listOf(downloaded)) }
|
||||
val patched = outputs["base.apk"] ?: outputs.values.firstOrNull()
|
||||
?: throw IllegalStateException("Patched apk not produced")
|
||||
?: throw IllegalStateException(translation["setup.patch.patched_not_produced_error"])
|
||||
patchedApkPath = patched.absolutePath
|
||||
pushStatus("Patched build ready. Install to finish.")
|
||||
pushStatus(translation["setup.patch.patched_ready_status"])
|
||||
}.onFailure {
|
||||
val message = it.message ?: it.toString()
|
||||
error = it.message ?: it.toString()
|
||||
pushStatus("Failed: ${it.message}")
|
||||
it.stackTraceToString()
|
||||
.lineSequence()
|
||||
.filter { line -> line.isNotBlank() }
|
||||
.forEach { line -> pushLog(line) }
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.failed_status",
|
||||
"message" to message
|
||||
)
|
||||
)
|
||||
}
|
||||
isRunning = false
|
||||
progress = -1f
|
||||
@@ -261,7 +300,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
fun markAlreadyInstalled() {
|
||||
installRequested = false
|
||||
installVerified = true
|
||||
pushLog("Marked as installed manually. You're cleared to continue.")
|
||||
pushLog(translation["setup.patch.mark_installed_log"])
|
||||
}
|
||||
|
||||
val accent = remember {
|
||||
@@ -273,14 +312,74 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
)
|
||||
}
|
||||
|
||||
if (showIssuesDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showIssuesDialog = false },
|
||||
title = translation["setup.patch.issues_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["setup.patch.issues_confirm"],
|
||||
onConfirm = { showIssuesDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.patch.issues_heading"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_conflict_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_adb_command"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
softWrap = false,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_invalid_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Auto Patcher",
|
||||
title = translation["setup.patch.title"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
JingmatrixBadge(accent)
|
||||
JingmatrixBadge(accent, translation)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
@@ -307,33 +406,52 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val isDownloading = progress >= 0f
|
||||
val isPatching = isRunning && downloadFinished && !isDownloading
|
||||
Text(
|
||||
text = if (progress >= 0f) {
|
||||
"Downloading Snapchat ${(progress * 100).toInt()}%"
|
||||
} else if (isRunning && downloadFinished) {
|
||||
"Patching..."
|
||||
} else {
|
||||
"Initializing..."
|
||||
text = when {
|
||||
isDownloading -> translation.format(
|
||||
"setup.patch.status_downloading",
|
||||
"percent" to (progress * 100).toInt().toString()
|
||||
)
|
||||
isPatching -> translation["setup.patch.status_patching"]
|
||||
else -> translation["setup.patch.status_initializing"]
|
||||
},
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = if (progress >= 0f) progress.coerceIn(0f, 1f) else 0f,
|
||||
color = PurrfectPalette.glowPrimary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
if (isDownloading) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
color = PurrfectPalette.glowPrimary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
color = PurrfectPalette.glowPrimary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogsPanel(
|
||||
logs = logs,
|
||||
pulse = logPulse,
|
||||
accent = accent
|
||||
accent = accent,
|
||||
translation = translation,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
|
||||
pushLog(translation["setup.patch.logs_copied"])
|
||||
}
|
||||
)
|
||||
|
||||
error?.let {
|
||||
@@ -367,7 +485,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Patched APK installed",
|
||||
text = translation["setup.patch.install_success"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -376,7 +494,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
} else {
|
||||
if (patchedApk == null) {
|
||||
GradientActionButton(
|
||||
label = "Start auto patch",
|
||||
label = translation["setup.patch.start_button"],
|
||||
icon = Icons.Filled.Download,
|
||||
onClick = { startPatch() },
|
||||
enabled = !isRunning
|
||||
@@ -384,11 +502,41 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
if (patchedApk != null) {
|
||||
GradientActionButton(
|
||||
label = "Install patched Snapchat",
|
||||
label = translation["setup.patch.install_button"],
|
||||
icon = Icons.Filled.Verified,
|
||||
onClick = { installPatchedApk() },
|
||||
enabled = true
|
||||
)
|
||||
val issuesInteraction = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scaleOnPress(issuesInteraction)
|
||||
.clickable(
|
||||
interactionSource = issuesInteraction,
|
||||
indication = null
|
||||
) { showIssuesDialog = true }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.issues_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
val manualInteraction = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -413,7 +561,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = "Already Installed?",
|
||||
text = translation["setup.patch.already_installed_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -429,7 +577,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JingmatrixBadge(accent: Brush) {
|
||||
private fun JingmatrixBadge(
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
@@ -455,7 +606,7 @@ private fun JingmatrixBadge(accent: Brush) {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Powered by Jingmatrix Lspatch",
|
||||
text = translation["setup.patch.powered_by_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -520,7 +671,9 @@ private fun GradientActionButton(
|
||||
private fun LogsPanel(
|
||||
logs: List<String>,
|
||||
pulse: Float,
|
||||
accent: Brush
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val animatedBrush = Brush.linearGradient(
|
||||
@@ -547,27 +700,63 @@ private fun LogsPanel(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Logs",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.patch.logs_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onCopy() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.patch.copy_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
logs.forEach { line ->
|
||||
Text(
|
||||
text = "- $line",
|
||||
text = translation.format(
|
||||
"setup.patch.log_line_prefix",
|
||||
"line" to line
|
||||
),
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
|
||||
@@ -73,9 +73,9 @@ class PermissionsScreen : SetupScreen() {
|
||||
|
||||
private fun descriptionFor(key: String): String {
|
||||
return when (key) {
|
||||
"notification_access" -> "Alerts you the second downloads finish."
|
||||
"battery_optimization" -> "Keeps background tasks alive without being killed."
|
||||
"display_over_other_apps" -> "Enables floating overlays while you are in Snapchat."
|
||||
"notification_access" -> context.translation["setup.permissions.notification_access_description"]
|
||||
"battery_optimization" -> context.translation["setup.permissions.battery_optimization_description"]
|
||||
"display_over_other_apps" -> context.translation["setup.permissions.display_over_other_apps_description"]
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ class PermissionsScreen : SetupScreen() {
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Granted",
|
||||
text = context.translation["setup.permissions.granted_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ class PickLanguageScreen : SetupScreen() {
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Current selection",
|
||||
text = context.translation["setup.pick_language.current_selection"],
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
@@ -180,9 +180,9 @@ class PickLanguageScreen : SetupScreen() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Browse languages")
|
||||
Text(text = context.translation["setup.pick_language.browse_languages"])
|
||||
}
|
||||
DialogText(text = "You can change this anytime from PurrfectSnap settings.")
|
||||
DialogText(text = context.translation["setup.pick_language.change_anytime_hint"])
|
||||
}
|
||||
|
||||
if (isDialog) {
|
||||
@@ -196,7 +196,7 @@ class PickLanguageScreen : SetupScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
StepTitle(
|
||||
title = "Available Languages",
|
||||
title = context.translation["setup.pick_language.available_languages"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
package me.eternal.purrfectsnap.ui.setup.screens.impl
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Verified
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
class RootInstallSnapchatScreen : SetupScreen() {
|
||||
private val autoPatchServer = AutoPatchServer()
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.HOURS)
|
||||
.connectTimeout(1, TimeUnit.HOURS)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.writeTimeout(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
private val targetPackage = "com.snapchat.android"
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val translation = context.translation
|
||||
val logs = remember { mutableStateListOf(translation["setup.root_install.ready_log"]) }
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var progress by remember { mutableFloatStateOf(-1f) }
|
||||
var downloadedApkPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val downloadedApk = remember(downloadedApkPath) { downloadedApkPath?.let(::File) }
|
||||
var isRunning by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var downloadStartedAt by rememberSaveable { mutableStateOf(0L) }
|
||||
var installVerified by rememberSaveable { mutableStateOf(false) }
|
||||
var installRequested by rememberSaveable { mutableStateOf(false) }
|
||||
var installWatcher by remember { mutableStateOf<Job?>(null) }
|
||||
var downloadFinished by rememberSaveable { mutableStateOf(false) }
|
||||
val logPulse by rememberInfiniteTransition(label = "rootInstallLogPulse").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(durationMillis = 2200, easing = FastOutSlowInEasing),
|
||||
RepeatMode.Reverse
|
||||
),
|
||||
label = "rootInstallLogPulseValue"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) { allowNext(false) }
|
||||
LaunchedEffect(installVerified) { allowNext(installVerified) }
|
||||
|
||||
fun pushLog(message: String) {
|
||||
if (logs.size > 120) logs.removeAt(0)
|
||||
logs.add(message)
|
||||
}
|
||||
|
||||
fun isSnapchatInstalledAfter(timestamp: Long): Boolean {
|
||||
if (timestamp == 0L) return false
|
||||
val info = runCatching {
|
||||
context.androidContext.packageManager.getPackageInfo(targetPackage, 0)
|
||||
}.getOrNull() ?: return false
|
||||
return info.lastUpdateTime >= timestamp
|
||||
}
|
||||
|
||||
fun isSnapchatInstalled(): Boolean {
|
||||
return runCatching { context.androidContext.packageManager.getPackageInfo(targetPackage, 0) }.isSuccess
|
||||
}
|
||||
|
||||
fun startInstallWatcher() {
|
||||
if (downloadStartedAt == 0L) return
|
||||
installWatcher?.cancel()
|
||||
installWatcher = coroutineScope.launch {
|
||||
repeat(80) {
|
||||
if (isSnapchatInstalledAfter(downloadStartedAt)) {
|
||||
installVerified = true
|
||||
pushLog(translation["setup.root_install.install_confirmed_log"])
|
||||
return@launch
|
||||
}
|
||||
delay(1200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(installRequested, downloadStartedAt) {
|
||||
if (installRequested && downloadStartedAt > 0) {
|
||||
startInstallWatcher()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(downloadStartedAt) {
|
||||
if (downloadStartedAt > 0 && isSnapchatInstalledAfter(downloadStartedAt)) {
|
||||
installVerified = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(installVerified) {
|
||||
if (installVerified) {
|
||||
downloadedApk?.let { runCatching { it.delete() } }
|
||||
downloadedApkPath = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pushStatus(message: String) = withContext(Dispatchers.Main) { pushLog(message) }
|
||||
suspend fun setProgress(value: Float) = withContext(Dispatchers.Main) { progress = value }
|
||||
|
||||
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
|
||||
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.download_recommended_status",
|
||||
"version" to latestApk.tagName
|
||||
)
|
||||
)
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val cacheDir = listOfNotNull(
|
||||
context.activity?.externalCacheDir,
|
||||
context.androidContext.externalCacheDir,
|
||||
context.androidContext.cacheDir
|
||||
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
|
||||
val outputFile = File(cacheDir, latestApk.apkName)
|
||||
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
|
||||
return@withContext null
|
||||
}
|
||||
outputFile.outputStream().use { output ->
|
||||
response.body?.byteStream()?.use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var read: Int
|
||||
var totalRead = 0L
|
||||
val totalSize = response.body?.contentLength() ?: -1L
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
totalRead += read
|
||||
if (totalSize > 0) setProgress(totalRead.toFloat() / totalSize.toFloat())
|
||||
}
|
||||
} ?: return@withContext null
|
||||
}
|
||||
setProgress(-1f)
|
||||
outputFile
|
||||
}
|
||||
}
|
||||
|
||||
fun installDownloadedApk() {
|
||||
val apk = downloadedApk ?: return
|
||||
installRequested = true
|
||||
startInstallWatcher()
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context.androidContext,
|
||||
"${context.androidContext.packageName}.fileprovider",
|
||||
apk
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.androidContext.startActivity(intent)
|
||||
}
|
||||
|
||||
fun markAlreadyInstalled() {
|
||||
installRequested = false
|
||||
installVerified = true
|
||||
pushLog(translation["setup.root_install.mark_installed_log"])
|
||||
}
|
||||
|
||||
fun startDownloadAndInstall() {
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
isRunning = true
|
||||
allowNext(false)
|
||||
error = null
|
||||
progress = -1f
|
||||
downloadedApkPath = null
|
||||
installVerified = false
|
||||
installRequested = false
|
||||
downloadFinished = false
|
||||
downloadStartedAt = System.currentTimeMillis()
|
||||
logs.clear()
|
||||
pushLog(translation["setup.root_install.start_download_log"])
|
||||
runCatching {
|
||||
if (isSnapchatInstalled()) {
|
||||
pushStatus(translation["setup.root_install.uninstall_prompt_status"])
|
||||
throw IllegalStateException(translation["setup.root_install.uninstall_prompt_error"])
|
||||
}
|
||||
pushStatus(translation["setup.root_install.fetching_apk_status"])
|
||||
val downloaded = downloadSnapchatFromAutoPatchServer()
|
||||
?: throw IllegalStateException(translation["setup.root_install.download_failed_error"])
|
||||
downloadedApkPath = downloaded.absolutePath
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.download_completed_status",
|
||||
"fileName" to downloaded.name
|
||||
)
|
||||
)
|
||||
downloadFinished = true
|
||||
pushStatus(translation["setup.root_install.launching_installer_status"])
|
||||
installDownloadedApk()
|
||||
}.onFailure {
|
||||
val message = it.message ?: it.toString()
|
||||
error = it.message ?: it.toString()
|
||||
it.stackTraceToString()
|
||||
.lineSequence()
|
||||
.filter { line -> line.isNotBlank() }
|
||||
.forEach { line -> pushLog(line) }
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.failed_status",
|
||||
"message" to message
|
||||
)
|
||||
)
|
||||
}
|
||||
isRunning = false
|
||||
progress = -1f
|
||||
}
|
||||
}
|
||||
|
||||
val accent = remember {
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowSecondary,
|
||||
PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = translation["setup.root_install.title"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.03f),
|
||||
tonalElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
AnimatedVisibility(visible = isRunning || progress >= 0f) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val isDownloading = progress >= 0f
|
||||
Text(
|
||||
text = if (isDownloading) {
|
||||
translation.format(
|
||||
"setup.root_install.status_downloading",
|
||||
"percent" to (progress * 100).toInt().toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.root_install.status_preparing"]
|
||||
},
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
if (isDownloading) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.coerceIn(0f, 1f) },
|
||||
color = PurrfectPalette.glowPrimary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
color = PurrfectPalette.glowPrimary,
|
||||
trackColor = Color.White.copy(alpha = 0.12f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogsPanel(
|
||||
logs = logs,
|
||||
pulse = logPulse,
|
||||
accent = accent,
|
||||
translation = translation,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
|
||||
pushLog(translation["setup.root_install.logs_copied"])
|
||||
}
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (installVerified) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.root_install.install_success"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (downloadedApk == null) {
|
||||
GradientActionButton(
|
||||
label = translation["setup.root_install.download_button"],
|
||||
icon = Icons.Filled.Download,
|
||||
onClick = { startDownloadAndInstall() },
|
||||
enabled = !isRunning
|
||||
)
|
||||
}
|
||||
if (downloadedApk != null) {
|
||||
GradientActionButton(
|
||||
label = translation["setup.root_install.install_button"],
|
||||
icon = Icons.Filled.Verified,
|
||||
onClick = { installDownloadedApk() },
|
||||
enabled = true
|
||||
)
|
||||
val manualInteraction = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scaleOnPress(manualInteraction)
|
||||
.clickable(
|
||||
interactionSource = manualInteraction,
|
||||
indication = null
|
||||
) { markAlreadyInstalled() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.root_install.already_installed_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GradientActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val gradient = Brush.horizontalGradient(listOf(PurrfectPalette.glowSecondary, PurrfectPalette.glowPrimary))
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.Transparent)
|
||||
.scaleOnPress(interaction)
|
||||
.clickable(
|
||||
enabled = enabled,
|
||||
interactionSource = interaction,
|
||||
indication = null,
|
||||
onClick = onClick
|
||||
),
|
||||
tonalElevation = 0.dp,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(if (enabled) gradient else Brush.horizontalGradient(listOf(Color.White.copy(alpha = 0.08f), Color.White.copy(alpha = 0.08f))))
|
||||
.padding(vertical = 14.dp, horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogsPanel(
|
||||
logs: List<String>,
|
||||
pulse: Float,
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val animatedBrush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.18f + 0.1f * pulse),
|
||||
Color.Transparent,
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.12f + 0.1f * (1 - pulse))
|
||||
),
|
||||
start = Offset.Zero,
|
||||
end = Offset(400f * (0.6f + pulse), 260f * (0.4f + (1 - pulse)))
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.03f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(animatedBrush)
|
||||
.background(Color.Black.copy(alpha = 0.25f))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["setup.root_install.logs_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { onCopy() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = translation["setup.root_install.copy_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
logs.forEach { line ->
|
||||
Text(
|
||||
text = translation.format(
|
||||
"setup.root_install.log_line_prefix",
|
||||
"line" to line
|
||||
),
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,13 +52,23 @@ class SaveFolderScreen : SetupScreen() {
|
||||
var currentFolder by remember {
|
||||
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
|
||||
}
|
||||
val readablePath = remember(currentFolder) {
|
||||
if (currentFolder.isBlank()) null
|
||||
else runCatching {
|
||||
val uri = android.net.Uri.parse(currentFolder)
|
||||
val path = uri.path ?: return@runCatching currentFolder
|
||||
if (path.contains("tree/")) {
|
||||
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
|
||||
} else currentFolder
|
||||
}.getOrElse { currentFolder }
|
||||
}
|
||||
var showNoPickerDialog by remember { mutableStateOf(false) }
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = context.translation["setup.dialogs.save_folder"],
|
||||
subtitle = null
|
||||
)
|
||||
DialogText(text = "Please choose the location where media should be downloaded to.")
|
||||
DialogText(text = context.translation["setup.save_folder.description"])
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
@@ -99,12 +109,12 @@ class SaveFolderScreen : SetupScreen() {
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Destination",
|
||||
text = context.translation["setup.save_folder.destination_label"],
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = if (currentFolder.isBlank()) "System default" else currentFolder,
|
||||
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
@@ -142,6 +152,28 @@ class SaveFolderScreen : SetupScreen() {
|
||||
) {
|
||||
Text(text = context.translation["setup.dialogs.select_save_folder_button"])
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
val defaultSrc = remember { MutableInteractionSource() }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
currentFolder = ""
|
||||
context.config.root.downloader.saveFolder.set("")
|
||||
context.sharedPreferences.edit().putBoolean("downloader_use_default_save_folder", true).apply()
|
||||
context.config.writeConfig()
|
||||
goNext()
|
||||
},
|
||||
interactionSource = defaultSrc,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scaleOnPress(defaultSrc),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color.White
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Text(text = context.translation["setup.save_folder.use_default_location_button"])
|
||||
}
|
||||
|
||||
if (showNoPickerDialog) {
|
||||
Dialog(onDismissRequest = { showNoPickerDialog = false }) {
|
||||
@@ -182,13 +214,13 @@ class SaveFolderScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Folder picker unavailable",
|
||||
text = context.translation["setup.save_folder.no_picker_title"],
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Some cloned/dual-app environments block the system folder picker. You can continue using the system default save location, or open the app outside clone mode to select a custom folder.",
|
||||
text = context.translation["setup.save_folder.no_picker_message"],
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -208,7 +240,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(context.translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -226,7 +258,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text("Use default")
|
||||
Text(context.translation["setup.save_folder.use_default_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +266,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
DialogText(
|
||||
text = "PurrfectSnap requires Storage permissions to download and Save Media from Snapchat."
|
||||
text = context.translation["setup.save_folder.permission_hint"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ class ActivityLauncherHelper(
|
||||
|
||||
fun launch(intent: Intent, callback: ActivityLauncherCallback, onFailure: ((Throwable) -> Unit)?) {
|
||||
if (this.callback != null) {
|
||||
throw IllegalStateException("Already launching an activity")
|
||||
val error = IllegalStateException("Already launching an activity")
|
||||
AbstractLogger.directError("Ignored concurrent activity launch", error)
|
||||
onFailure?.invoke(error)
|
||||
return
|
||||
}
|
||||
this.callback = callback
|
||||
|
||||
@@ -56,7 +59,11 @@ class ActivityLauncherHelper(
|
||||
|
||||
fun requestPermission(permission: String, callback: ActivityLauncherCallback) {
|
||||
if (this.callback != null) {
|
||||
throw IllegalStateException("Already launching an activity")
|
||||
AbstractLogger.directError(
|
||||
"Ignored concurrent permission request",
|
||||
IllegalStateException("Already launching an activity")
|
||||
)
|
||||
return
|
||||
}
|
||||
this.callback = callback
|
||||
permissionResultLauncher.launch(permission)
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog as StandardDialog
|
||||
import androidx.core.net.toUri
|
||||
@@ -56,8 +57,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
@@ -207,23 +210,41 @@ class AlertDialogs(
|
||||
@Composable
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun UniqueSelectionDialog(property: PropertyPair<*>) {
|
||||
val disabledKey = property.key.params.disabledKey
|
||||
val noDisable = property.key.params.flags.contains(ConfigFlag.NO_DISABLE_KEY)
|
||||
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
|
||||
add(0, "null")
|
||||
if (noDisable) {
|
||||
disabledKey?.let { remove(it) }
|
||||
remove("null")
|
||||
} else {
|
||||
val disabledEntry = disabledKey ?: "null"
|
||||
remove(disabledEntry)
|
||||
add(0, disabledEntry)
|
||||
if (disabledKey == null) {
|
||||
remove("null")
|
||||
add(0, "null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedValue by remember {
|
||||
mutableStateOf(property.value.getNullable()?.toString() ?: "null")
|
||||
val currentValue = property.value.getNullable()?.toString()
|
||||
mutableStateOf(currentValue ?: if (noDisable) (keys.firstOrNull().orEmpty()) else (disabledKey ?: "null"))
|
||||
}
|
||||
|
||||
DefaultDialogCard {
|
||||
keys.forEachIndexed { index, item ->
|
||||
fun select() {
|
||||
selectedValue = item
|
||||
property.value.setAny(if (index == 0) {
|
||||
null
|
||||
} else {
|
||||
item
|
||||
})
|
||||
if (!noDisable && disabledKey != null && item == disabledKey) {
|
||||
property.value.setAny(disabledKey)
|
||||
return
|
||||
}
|
||||
if (!noDisable && disabledKey == null && index == 0) {
|
||||
property.value.setAny(null)
|
||||
return
|
||||
}
|
||||
property.value.setAny(item)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -279,6 +300,7 @@ class AlertDialogs(
|
||||
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
@@ -306,7 +328,7 @@ class AlertDialogs(
|
||||
Button(
|
||||
onClick = {
|
||||
if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) {
|
||||
Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n
|
||||
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
@@ -362,6 +384,7 @@ class AlertDialogs(
|
||||
fieldValue.value = it
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
@@ -476,7 +499,7 @@ class AlertDialogs(
|
||||
currentColor = null
|
||||
}
|
||||
},
|
||||
label = { Text(text = "Hex Color") },
|
||||
label = { Text(text = translation["dialogs.hex_color_label"]) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
@@ -571,6 +594,8 @@ class AlertDialogs(
|
||||
property: PropertyPair<*>,
|
||||
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
|
||||
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
|
||||
locationSearchProvider: String = "osm",
|
||||
googleMapsApiKey: String = "",
|
||||
saveCoordinates: (() -> Unit)? = null,
|
||||
dismiss: () -> Unit = {}
|
||||
) {
|
||||
@@ -590,7 +615,20 @@ class AlertDialogs(
|
||||
MapView(context).apply {
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
val tileSource = if (locationSearchProvider == "google_maps") {
|
||||
object : OnlineTileSourceBase(
|
||||
"GoogleMaps",
|
||||
0, 19, 256, ".png",
|
||||
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TileSourceFactory.MAPNIK
|
||||
}
|
||||
setTileSource(tileSource)
|
||||
|
||||
val startPoint = GeoPoint(coordinates.first, coordinates.second)
|
||||
controller.setZoom(10.0)
|
||||
@@ -665,28 +703,56 @@ class AlertDialogs(
|
||||
val resultsScrollState = rememberScrollState()
|
||||
|
||||
suspend fun search() {
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
|
||||
.header("User-Agent", Constants.USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@use
|
||||
if (locationSearchProvider == "google_maps") {
|
||||
// Google Maps Search
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
|
||||
.appendQueryParameter("address", locationName)
|
||||
.appendQueryParameter("key", googleMapsApiKey)
|
||||
.build().toString())
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
|
||||
if (jsonResponse.has("results")) {
|
||||
val results = jsonResponse.getAsJsonArray("results")
|
||||
addressResults = results.take(5).map { jsonElement ->
|
||||
val result = jsonElement.asJsonObject
|
||||
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
|
||||
Triple(
|
||||
result.get("formatted_address").asString,
|
||||
geometry.get("lat").asString,
|
||||
geometry.get("lng").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
} else {
|
||||
// OSM Nominatim Search (Existing Logic)
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
|
||||
.appendQueryParameter("q", locationName)
|
||||
.appendQueryParameter("format", "jsonv2")
|
||||
.build().toString())
|
||||
.header("User-Agent", Constants.OSM_USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchJob = null
|
||||
}
|
||||
|
||||
@@ -750,7 +816,7 @@ class AlertDialogs(
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Search or tap on the map",
|
||||
text = betterLocationTranslation["search_or_tap_map_hint"],
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
@@ -804,11 +870,11 @@ class AlertDialogs(
|
||||
search()
|
||||
}
|
||||
},
|
||||
placeholder = { Text(text = "Search location...") },
|
||||
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Search",
|
||||
contentDescription = betterLocationTranslation["search_icon_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
@@ -928,7 +994,7 @@ class AlertDialogs(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Searching...",
|
||||
text = betterLocationTranslation["searching_label"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
@@ -1076,7 +1142,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lat.value,
|
||||
onValueChange = { lat.value = it },
|
||||
label = { Text(text = "Latitude") },
|
||||
label = { Text(text = translation["latitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1096,7 +1162,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lon.value,
|
||||
onValueChange = { lon.value = it },
|
||||
label = { Text(text = "Longitude") },
|
||||
label = { Text(text = translation["longitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
|
||||
@@ -2,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Returns true if the user has disabled animator duration scale at the system level.
|
||||
* This respects Accessibility/Developer settings where motion is reduced or disabled.
|
||||
*/
|
||||
fun prefersReducedMotion(context: Context): Boolean {
|
||||
return runCatching {
|
||||
@@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean {
|
||||
|
||||
@Composable
|
||||
fun rememberPrefersReducedMotion(): Boolean {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
|
||||
// One-shot read; if you need to observe changes live, add a ContentObserver.
|
||||
LaunchedEffect(Unit) {
|
||||
state.value = prefersReducedMotion(context)
|
||||
}
|
||||
@@ -47,18 +35,34 @@ fun rememberPrefersReducedMotion(): Boolean {
|
||||
}
|
||||
|
||||
object Motion {
|
||||
/**
|
||||
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
|
||||
*/
|
||||
const val HEADER_MORPH_THRESHOLD = 300f
|
||||
|
||||
/**
|
||||
* Milliseconds per pixel for marquee scrolling speed.
|
||||
*/
|
||||
const val MARQUEE_CADENCE = 20
|
||||
|
||||
// 1. High-end Spring Physics for "Alive" UI
|
||||
val springDynamic = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
|
||||
fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Int> {
|
||||
val reduced = rememberPrefersReducedMotion()
|
||||
val d = if (reduced) 0 else durationMillis
|
||||
return tween<Int>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
|
||||
fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Float> {
|
||||
val reduced = rememberPrefersReducedMotion()
|
||||
val d = if (reduced) 0 else durationMillis
|
||||
return tween<Float>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -68,23 +72,23 @@ object Motion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
|
||||
* Pass the same [interactionSource] into the clickable component to synchronize state.
|
||||
* Kinetic scale-down on press using spring physics.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.scaleOnPress(
|
||||
interactionSource: InteractionSource,
|
||||
enabled: Boolean = true,
|
||||
scaleDown: Float = 0.98f
|
||||
scaleDown: Float = 0.96f
|
||||
): Modifier {
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val target = if (pressed) scaleDown else 1f
|
||||
val spec = Motion.tweenFloatSpec(150)
|
||||
val animated by androidx.compose.animation.core.animateFloatAsState(
|
||||
|
||||
val animated by animateFloatAsState(
|
||||
targetValue = target,
|
||||
animationSpec = spec,
|
||||
animationSpec = Motion.springDynamic,
|
||||
label = "pressScale"
|
||||
)
|
||||
|
||||
return this.then(Modifier.graphicsLayer {
|
||||
scaleX = animated
|
||||
scaleY = animated
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A specialized modifier that tracks the height of the header (FloatingTopBar)
|
||||
* and provides it to the caller. This eliminates repetitive onGloballyPositioned
|
||||
* logic across all sub-pages.
|
||||
*
|
||||
* @param onHeightChanged Callback triggered when the header height is measured.
|
||||
*/
|
||||
fun Modifier.headerHeightTracker(
|
||||
onHeightChanged: (Dp) -> Unit
|
||||
): Modifier = composed {
|
||||
val density = LocalDensity.current
|
||||
this.onGloballyPositioned { coordinates ->
|
||||
val heightDp = with(density) { coordinates.size.height.toDp() }
|
||||
onHeightChanged(heightDp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
|
||||
/**
|
||||
* A premium marquee text component that supports continuous looping.
|
||||
* Optimized using TextMeasurer for robust width calculation.
|
||||
*/
|
||||
@Composable
|
||||
fun PurrfectMarqueeText(
|
||||
text: String,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.White,
|
||||
textAlign: TextAlign = TextAlign.Center,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
delayMillis: Int = 1500,
|
||||
velocity: Dp = 30.dp,
|
||||
maxLines: Int = 1,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.clipToBounds(),
|
||||
contentAlignment = contentAlignment
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
textAlign = textAlign,
|
||||
maxLines = maxLines,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (enabled) {
|
||||
Modifier.basicMarquee(
|
||||
iterations = Int.MAX_VALUE,
|
||||
repeatDelayMillis = delayMillis,
|
||||
initialDelayMillis = delayMillis,
|
||||
velocity = velocity
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable/pfp_external.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_external.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
app/src/main/res/drawable/pfp_rsr.jpg
Normal file
BIN
app/src/main/res/drawable/pfp_rsr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user