Compare commits
308 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdbcd20d65 | ||
|
|
79a1114069 | ||
|
|
d724e5c615 | ||
|
|
73225b7151 | ||
|
|
546c9e1410 | ||
|
|
c0c395b100 | ||
|
|
beede425df | ||
|
|
ed66ae2998 | ||
|
|
9934bd2f23 | ||
|
|
cb2c0dded9 | ||
|
|
d4a5a60cc1 | ||
|
|
cbe9754fba | ||
|
|
f3fb9667d8 | ||
|
|
347115f8d3 | ||
|
|
d19257869b | ||
|
|
5978717065 | ||
|
|
c988c489e7 | ||
|
|
0e549a1565 | ||
|
|
2be1c3258d | ||
|
|
c13759e99a | ||
|
|
fb94a2e8a3 | ||
|
|
5651d48d96 | ||
|
|
8f8c696431 | ||
|
|
e408a505ab | ||
|
|
335cc00dfb | ||
|
|
ded49b8b1b | ||
|
|
d9593fa629 | ||
|
|
a20496329c | ||
|
|
29f05a2d7f | ||
|
|
ac2e39eccf | ||
|
|
a18fc8ce37 | ||
|
|
cab8c69f72 | ||
|
|
4bd13a9d09 | ||
|
|
616ae0d3b3 | ||
|
|
c0c5503864 | ||
|
|
98e04a81cb | ||
|
|
61dcfd9b57 | ||
|
|
947bb399c8 | ||
|
|
fa5c7e0771 | ||
|
|
dde94aeffc | ||
|
|
4e79a22b43 | ||
|
|
5037f393f7 | ||
|
|
7d30e3b632 | ||
|
|
05b4e01d27 | ||
|
|
5b12844a24 | ||
|
|
e23bf443a1 | ||
|
|
b5a31bee7b | ||
|
|
9901812db6 | ||
|
|
0767289210 | ||
|
|
5de180336e | ||
|
|
632df791bd | ||
|
|
c09a5df615 | ||
|
|
9d5504dee0 | ||
|
|
0ad9d0a5dc | ||
|
|
7499b08494 | ||
|
|
ec6e4deae8 | ||
|
|
839ab8232a | ||
|
|
50def2be77 | ||
|
|
cf1f691762 | ||
|
|
22bbadf926 | ||
|
|
760d652f5f | ||
|
|
22ee866205 | ||
|
|
ce3361992c | ||
|
|
768a3b7c09 | ||
|
|
b0715b3f27 | ||
|
|
b6435f9010 | ||
|
|
e0431bd001 | ||
|
|
d86d9db508 | ||
|
|
068b272b5e | ||
|
|
0aa1317950 | ||
|
|
5a4fdd3664 | ||
|
|
65086b79b3 | ||
|
|
e201d7046c | ||
|
|
1e55a83ca0 | ||
|
|
f95a4d8552 | ||
|
|
ff097fbe41 | ||
|
|
4b92304d84 | ||
|
|
9c226d1bb9 | ||
|
|
7005fdf567 | ||
|
|
10a820e93e | ||
|
|
cd8d358fa6 | ||
|
|
73616f2158 | ||
|
|
8e1628e28c | ||
|
|
9223d2debc | ||
|
|
d61eb68d8b | ||
|
|
3dccadc5c0 | ||
|
|
98f0db6a15 | ||
|
|
9fa96125b2 | ||
|
|
992a48fa81 | ||
|
|
360540edb4 | ||
|
|
4a17989c96 | ||
|
|
81796c8824 | ||
|
|
73ed1a9647 | ||
|
|
2faa368722 | ||
|
|
2913cfe794 | ||
|
|
4e3ef029a1 | ||
|
|
3e07d4c419 | ||
|
|
83e8865266 | ||
|
|
dbf6de8a65 | ||
|
|
6f4e746a66 | ||
|
|
df76d13904 | ||
|
|
7b7bceec80 | ||
|
|
61bcb6d743 | ||
|
|
0689cc2005 | ||
|
|
279e847d00 | ||
|
|
a859d4825b | ||
|
|
25dd438da2 | ||
|
|
2c9f5d45e2 | ||
|
|
e89628e5c2 | ||
|
|
6f0639b8e9 | ||
|
|
ba607fb419 | ||
|
|
b682ee8432 | ||
|
|
7d7488d05e | ||
|
|
4f0db150b0 | ||
|
|
810afd88f7 | ||
|
|
ed6786e65b | ||
|
|
0db1c5036e | ||
|
|
cc7539d262 | ||
|
|
ca37953476 | ||
|
|
050dea3214 | ||
|
|
724d7efb1e | ||
|
|
a855983c46 | ||
|
|
7dd9ada91d | ||
|
|
3d48b14def | ||
|
|
6d1eb89adf | ||
|
|
b44558babd | ||
|
|
e97a37d18e | ||
|
|
281d55689a | ||
|
|
7475998961 | ||
|
|
f01b6c2f9a | ||
|
|
98f8d6ebea | ||
|
|
015226cb40 | ||
|
|
ad06b0dffb | ||
|
|
4b4d64e8eb | ||
|
|
3eec22c615 | ||
|
|
9a2e6065dc | ||
|
|
8e00c29a59 | ||
|
|
9ec9517ec5 | ||
|
|
e822fc20b4 | ||
|
|
f3c794fc47 | ||
|
|
6e6d311c23 | ||
|
|
a82db6a863 | ||
|
|
9781008b2e | ||
|
|
c71e06095d | ||
|
|
1fdaf6d55d | ||
|
|
8a9258b318 | ||
|
|
6491288513 | ||
|
|
33664c1112 | ||
|
|
bb8e644b4c | ||
|
|
21fba253b1 | ||
|
|
9e5b318120 | ||
|
|
00c5d60b7b | ||
|
|
21cd306132 | ||
|
|
0d42aed0ff | ||
|
|
f8fdd1893f | ||
|
|
a8e2148b26 | ||
|
|
1e9ad8eb2b | ||
|
|
182e7eefeb | ||
|
|
070a8ffaf7 | ||
|
|
95f98221e3 | ||
|
|
dbbc52b67f | ||
|
|
b7c8042a93 | ||
|
|
141b5e16a0 | ||
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 | ||
|
|
7a8ad81d48 | ||
|
|
7db60d6eb1 | ||
|
|
565a8b8287 | ||
|
|
1c612ebc8e | ||
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 | ||
|
|
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 |
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
|
||||
|
||||
98
.github/workflows/debug.yml
vendored
@@ -105,101 +105,3 @@ jobs:
|
||||
with:
|
||||
name: purrfectsnap-armv8-debug
|
||||
path: app/build/outputs/apk/armv8/debug/*.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 assembleArmv7Debug
|
||||
- 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/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: |
|
||||
./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/debug/ -type f ! -name '*-signed*' -delete
|
||||
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
|
||||
|
||||
47
.github/workflows/pull_request.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
run: npm install typescript -g
|
||||
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
run: rustup target add aarch64-linux-android
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv8Debug
|
||||
@@ -46,49 +46,6 @@ jobs:
|
||||
with:
|
||||
name: purrfectsnap-armv8-debug
|
||||
path: app/build/outputs/apk/armv8/debug/*.apk
|
||||
|
||||
job_armv7:
|
||||
runs-on: macos-latest
|
||||
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: Build
|
||||
run: ./gradlew --configuration-cache assembleArmv7Debug
|
||||
|
||||
- name: Get current build version
|
||||
id: version-env
|
||||
run: |
|
||||
./gradlew --configuration-cache getVersion
|
||||
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
|
||||
|
||||
- name: Rename APK file
|
||||
run: |
|
||||
mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-debug
|
||||
path: app/build/outputs/apk/armv7/debug/*.apk
|
||||
|
||||
job_manager:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -136,7 +93,7 @@ jobs:
|
||||
run: chmod +x gradlew
|
||||
|
||||
- name: Add Android targets for Rust
|
||||
run: rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
run: rustup target add aarch64-linux-android
|
||||
|
||||
- name: Build
|
||||
run: ./gradlew --configuration-cache assembleCoreDebug
|
||||
|
||||
114
.github/workflows/release.yml
vendored
@@ -6,6 +6,8 @@ on:
|
||||
description: 'Upload to CI channel'
|
||||
required: false
|
||||
type: boolean
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
jobs:
|
||||
job_armv8:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,11 +15,11 @@ jobs:
|
||||
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v4.7.0
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
@@ -27,7 +29,7 @@ jobs:
|
||||
- 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
|
||||
run: rustup target add aarch64-linux-android
|
||||
- name: Prepare release keystore for Gradle signing
|
||||
env:
|
||||
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
|
||||
@@ -101,118 +103,20 @@ jobs:
|
||||
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
|
||||
uses: actions/upload-artifact@v4.6.2
|
||||
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]
|
||||
needs: [job_armv8]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v4.2.1
|
||||
with:
|
||||
path: ./all-apks
|
||||
- name: Display all files for debug
|
||||
|
||||
5
.gitignore
vendored
@@ -4,9 +4,11 @@ local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
**/build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
.local/
|
||||
native/.omvll/
|
||||
cloudflare/**/.wrangler/
|
||||
cloudflare/**/node_modules/
|
||||
@@ -18,3 +20,6 @@ cloudflare/**/allowed_codes.local.*
|
||||
security/allowed-codes.local.*
|
||||
security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
|
||||
265
README.md
@@ -1,6 +1,10 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap" width="180"/>
|
||||
<div align="center">
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/banner.jpg" alt="PurrfectSnap" width="100%"/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
# PurrfectSnap
|
||||
|
||||
@@ -12,7 +16,7 @@
|
||||
[](https://github.com/particle-box/PurrfectSnap/releases)
|
||||
[](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
|
||||
|
||||
[Installation](#installation) • [Features](#features) • [Changelog](#whats-new) • [Community](#community)
|
||||
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -36,44 +40,9 @@ No aggressive donation requests. No minimal changes disguised as “major update
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## 🔄 How It Works
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Install PurrfectSnap] --> B{Device Type?}
|
||||
B -->|Non-Rooted| C[Auto Patcher Runs]
|
||||
B -->|Rooted| D[Xposed Framework]
|
||||
C --> E[Snapchat Patched Automatically]
|
||||
D --> E
|
||||
E --> F[Launch Snapchat]
|
||||
F --> G[Access PurrfectSnap Settings]
|
||||
G --> H[Configure Features]
|
||||
H --> I{Enable Features}
|
||||
I --> J[Media Downloader]
|
||||
I --> K[Privacy Controls]
|
||||
I --> L[UI Customization]
|
||||
I --> M[Camera Enhancements]
|
||||
I --> N[AI Intelligence Layer]
|
||||
J --> O[Enhanced Snapchat Experience]
|
||||
K --> O
|
||||
L --> O
|
||||
M --> O
|
||||
N --> O
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## Features
|
||||
|
||||
PurrfectSnap offers deep control across media, privacy, automation, and UI—designed for both casual users and power users.
|
||||
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -242,152 +211,6 @@ Extensibility through custom scripts.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## What's New
|
||||
|
||||
Here's what makes PurrfectSnap different from upstream SnapEnhance.
|
||||
|
||||
<br>
|
||||
|
||||
### Aurora Design System
|
||||
|
||||
A complete visual language rebuilt from the ground up. Not just a theme, an entire design philosophy.
|
||||
|
||||
The interface now flows with purpose. Subtle animations guide your interactions. Every screen has been reconsidered, every transition refined. The result is an experience that feels premium without being ostentatious.
|
||||
|
||||
<br>
|
||||
|
||||
### PurrAura
|
||||
|
||||
The ban problem? Solved.
|
||||
|
||||
No more sacrificing features to stay safe. Add friends, block users, attach music; everything works as it should. No workarounds, no compromises, no disadvantages.
|
||||
|
||||
<br>
|
||||
|
||||
### Auto Patcher
|
||||
|
||||
Installation was complicated. Now it isn’t.
|
||||
|
||||
We've eliminated 90% of the installation friction. No Shizuku. No LSPatch. Just a straightforward process that respects your time.
|
||||
|
||||
<br>
|
||||
|
||||
### Intelligence Layer
|
||||
|
||||
**Auto Reply**
|
||||
Let AI handle routine responses while you focus on what matters. Configure automatic replies for chats, stories, and half-swipes. Essential for creators managing volume. Requires an API key from Google AI Studio, they offer free tier access.
|
||||
|
||||
**Scheduled Snaps**
|
||||
Time-shifted communication. Set a snap to send at midnight for a birthday. Queue content for optimal timing:your schedule, automated.
|
||||
|
||||
**Message Translator**
|
||||
Language barriers, eliminated. Incoming messages automatically translate to your preferred language. Set it once, communicate globally.
|
||||
|
||||
<br>
|
||||
|
||||
### Privacy Enhancements
|
||||
|
||||
**Granular Controls**
|
||||
Whitelist and blacklist modes for typing indicators and message logging. Choose exactly who sees what, friend by friend.
|
||||
|
||||
**Auto Delete Messages**
|
||||
Set expiration times for your messages. They disappear on your schedule, not Snapchat's.
|
||||
|
||||
**Message Logger Viewer**
|
||||
Review exported logs directly in-app. No external tools required.
|
||||
|
||||
<br>
|
||||
|
||||
### Data Persistence
|
||||
|
||||
**Friend Notes Backup**
|
||||
Your notes are valuable. Now they're protected. Export and restore with ease. Never lose context again.
|
||||
|
||||
<br>
|
||||
|
||||
### Device Management
|
||||
|
||||
**ID Spoofing**
|
||||
Device bans happen. Sometimes unjustly. We offer a solution for legitimate cases, approval required with proof of wrongful ban. This feature is restricted to prevent abuse.
|
||||
|
||||
**Model Spoofing**
|
||||
Identify as a high-end device model. Potential performance improvements, enhanced camera processing, and better feature access.
|
||||
|
||||
**Network Spoofing**
|
||||
Spoof your network status to Wi-Fi always to prevent several restrictions on mobile data.
|
||||
|
||||
<br>
|
||||
|
||||
### Restored Functionality
|
||||
|
||||
Features that disappeared? They're back.
|
||||
|
||||
Bulk messaging works again. Friend list management is fully operational. These aren't new features, they're restored capabilities that should never have been lost.
|
||||
|
||||
<br>
|
||||
|
||||
### Discovery
|
||||
|
||||
**Scripts Catalog**
|
||||
Browse and install scripts without leaving the app. No more hunting for import links. One-tap installation. Always current.
|
||||
|
||||
**Friend Tracker Catalog**
|
||||
Browse and install tracking rules without leaving the app. One-tap installation. Always current.
|
||||
|
||||
**Friend Tracker Import/Export**
|
||||
Backup and restore your Friend Tracker configurations with ease.
|
||||
|
||||
<br>
|
||||
|
||||
### Advanced Tracking
|
||||
|
||||
**I Can See You Rule**
|
||||
Track the exact time and duration when a friend enters your chat. Precision visibility tracking for Friend Tracker.
|
||||
|
||||
**Auto Open Snaps Enhancement**
|
||||
More granular control with queue size configuration and customizable delays.
|
||||
|
||||
<br>
|
||||
|
||||
### Visual Refinements
|
||||
|
||||
**AMOLED Theme**
|
||||
True black. Battery-saving. The most requested feature, delivered.
|
||||
|
||||
**Haptic Feedback**
|
||||
Subtle tactile responses. Every interaction confirmed through touch.
|
||||
|
||||
**Customizable Bottom Bar**
|
||||
Your navigation, your rules. Rearrange tabs, remove what you don't use, set your default view. Complete flexibility.
|
||||
|
||||
**Search History**
|
||||
Recently searched features, instantly accessible. No more hunting through menus.
|
||||
|
||||
<br>
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**In-App Updates**
|
||||
New versions install directly from within the app. No GitHub hunting. No manual downloads. Just seamless updates.
|
||||
|
||||
**Update Notifications**
|
||||
Stay current automatically. Get notified when new releases are available.
|
||||
|
||||
**Config Export/Import Summary**
|
||||
See exactly which features a config will enable before importing or exporting. Know what you're getting.
|
||||
|
||||
<br>
|
||||
|
||||
### Quality
|
||||
|
||||
Hundreds of refinements you'll feel but might not see. Smoother animations. Faster response times. Eliminated edge cases. The kind of polish that separates good from exceptional.
|
||||
|
||||
|
||||
|
||||
> ⚠️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!**
|
||||
|
||||
@@ -421,15 +244,79 @@ Questions? Ideas? Found a bug? Our community is active and responsive.
|
||||
|
||||
<br>
|
||||
|
||||
## Contributing
|
||||
## 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 :)
|
||||
|
||||
<br>
|
||||
### 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.
|
||||
|
||||
<br>
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -437,7 +324,7 @@ PurrfectSnap is built with exceptional open source tools. We do not collect any
|
||||
|
||||
**Core Dependencies**
|
||||
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
|
||||
- [libxposed](https://github.com/libxposed/api) — Framework integration
|
||||
- [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
|
||||
|
||||
@@ -1 +1 @@
|
||||
- Test
|
||||
- Temporary fix for conversations disappearing: Turn off Block Ads feature
|
||||
|
||||
@@ -113,7 +113,6 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
@@ -151,7 +150,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()}\"")
|
||||
@@ -166,6 +165,9 @@ android {
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
// Keep local release builds installable when private release credentials are unavailable.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
debug {
|
||||
@@ -267,6 +269,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)
|
||||
@@ -355,6 +358,7 @@ afterEvaluate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
properties["debug_flavor"]?.let {
|
||||
|
||||
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.** { *; }
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -7,6 +7,8 @@ import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.core.app.CoreComponentFactory
|
||||
@@ -30,12 +32,14 @@ import androidx.work.WorkManager
|
||||
import me.eternal.purrfectsnap.bridge.BridgeService
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
import me.eternal.purrfectsnap.common.ReceiversConfig
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
|
||||
import me.eternal.purrfectsnap.common.config.ModConfig
|
||||
import me.eternal.purrfectsnap.common.logger.fatalCrash
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.common.util.constantLazyBridge
|
||||
import me.eternal.purrfectsnap.common.util.getPurgeTime
|
||||
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
|
||||
@@ -275,6 +279,67 @@ class RemoteSideContext(
|
||||
androidContext.startActivity(intent)
|
||||
}
|
||||
|
||||
fun requestSocialSnapshotRefresh(
|
||||
openSnapchatFirst: Boolean = true,
|
||||
snapchatWarmupDelayMs: Long = 1200L,
|
||||
returnDelayMs: Long = 1200L
|
||||
) {
|
||||
fun sendSocialSnapshotBroadcast() {
|
||||
runCatching {
|
||||
androidContext.sendBroadcast(
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("Failed to request latest social snapshot", it)
|
||||
}
|
||||
}
|
||||
|
||||
if (!openSnapchatFirst) {
|
||||
sendSocialSnapshotBroadcast()
|
||||
return
|
||||
}
|
||||
|
||||
val snapchatIntent = androidContext.packageManager
|
||||
.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
|
||||
if (snapchatIntent == null) {
|
||||
shortToast(translation["toast_snapchat_not_installed"])
|
||||
sendSocialSnapshotBroadcast()
|
||||
return
|
||||
}
|
||||
|
||||
val returnIntent = Intent(androidContext, MainActivity::class.java).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
)
|
||||
}
|
||||
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
runCatching {
|
||||
androidContext.startActivity(snapchatIntent)
|
||||
mainHandler.postDelayed(
|
||||
{
|
||||
runCatching {
|
||||
androidContext.startActivity(returnIntent)
|
||||
}.onFailure {
|
||||
log.error("Failed to return to PurrfectSnap after Snapchat handoff", it)
|
||||
}
|
||||
mainHandler.postDelayed(
|
||||
{ sendSocialSnapshotBroadcast() },
|
||||
returnDelayMs
|
||||
)
|
||||
},
|
||||
snapchatWarmupDelayMs
|
||||
)
|
||||
}.onFailure {
|
||||
log.error("Failed to launch Snapchat for social snapshot refresh", it)
|
||||
sendSocialSnapshotBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleAnnouncementCheck() {
|
||||
val workManager = WorkManager.getInstance(androidContext)
|
||||
val constraints = Constraints.Builder()
|
||||
|
||||
@@ -4,6 +4,10 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.RemoteException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
@@ -27,10 +31,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
|
||||
}
|
||||
@@ -47,36 +66,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)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +162,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
|
||||
)
|
||||
@@ -162,6 +203,7 @@ class BridgeService : Service() {
|
||||
}
|
||||
|
||||
override fun sync(callback: SyncCallback) {
|
||||
clearSyncCallback()
|
||||
syncCallback = callback
|
||||
measureTimeMillis {
|
||||
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
|
||||
@@ -180,15 +222,42 @@ class BridgeService : Service() {
|
||||
triggerScopeSync(SocialScope.getByName(scope), id, true)
|
||||
}
|
||||
|
||||
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
|
||||
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
|
||||
|
||||
override fun passGroupsAndFriends(
|
||||
groups: List<String>,
|
||||
friends: List<String>
|
||||
friends: List<String>,
|
||||
chunkIndex: Int,
|
||||
totalChunks: Int
|
||||
) {
|
||||
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
|
||||
remoteSideContext.database.receiveMessagingDataCallback(
|
||||
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
|
||||
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
|
||||
)
|
||||
synchronized(friendAccumulator) {
|
||||
if (chunkIndex == 0) {
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
}
|
||||
|
||||
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
|
||||
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
|
||||
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
|
||||
|
||||
if (chunkIndex == totalChunks - 1) {
|
||||
val finalFriends = friendAccumulator.toList()
|
||||
val finalGroups = groupAccumulator.toList()
|
||||
|
||||
friendAccumulator.clear()
|
||||
groupAccumulator.clear()
|
||||
|
||||
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
|
||||
pendingSocialSnapshotCallback?.let { callback ->
|
||||
pendingSocialSnapshotCallback = null
|
||||
callback(finalFriends, finalGroups)
|
||||
}
|
||||
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
|
||||
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getScopeNotes(id: String): String? {
|
||||
|
||||
@@ -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")
|
||||
@@ -114,82 +121,41 @@ class DownloadProcessor (
|
||||
inputFile.outputStream().use {
|
||||
bitmap.compress(compressFormat, 100, it)
|
||||
}
|
||||
bitmap.recycle()
|
||||
fileType = FileType.fromFile(inputFile)
|
||||
}
|
||||
}
|
||||
|
||||
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 +171,155 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
|
||||
stream1.use { s1 ->
|
||||
stream2.use { s2 ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
while (true) {
|
||||
val read1 = s1.read(buffer1)
|
||||
val read2 = s2.read(buffer2)
|
||||
if (read1 != read2) return false
|
||||
if (read1 == -1) return true
|
||||
for (i in 0 until read1) {
|
||||
if (buffer1[i] != buffer2[i]) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 buildOutputFileName(outputPath: String, fileType: FileType): String {
|
||||
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
|
||||
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
|
||||
|
||||
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
|
||||
rawName.substringBeforeLast(".")
|
||||
} else {
|
||||
rawName
|
||||
}
|
||||
|
||||
return "${sanitizeFileName(baseName)}.$targetExtension"
|
||||
}
|
||||
|
||||
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.trimEnd('/').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 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
|
||||
}
|
||||
}
|
||||
|
||||
var finalFileName = fileName
|
||||
var collisionCount = 0
|
||||
|
||||
while (true) {
|
||||
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
|
||||
|
||||
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
collisionCount++
|
||||
finalFileName = appendNameSuffix(fileName, collisionCount)
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
|
||||
?: throw Exception("Failed to create output file $finalFileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $finalFileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
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("\\", "/")
|
||||
)
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
fileType.isVideo -> Environment.DIRECTORY_MOVIES
|
||||
@@ -222,40 +328,65 @@ class DownloadProcessor (
|
||||
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
|
||||
.joinToString("/") + "/"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val collection = when {
|
||||
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
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
|
||||
|
||||
for (attempt in 0..100) {
|
||||
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
|
||||
|
||||
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
|
||||
if (contentMatches(existingUri, inputFile)) {
|
||||
return GallerySaveResult(existingUri, alreadyDownloaded = true)
|
||||
}
|
||||
return@let
|
||||
} ?: run {
|
||||
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) }.getOrNull() ?: return@run
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("Failed to allocate unique filename")
|
||||
} 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)
|
||||
FileOutputStream(destFile).use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
|
||||
var destFile = File(destDir, fileName)
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
|
||||
}
|
||||
|
||||
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(
|
||||
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = Uri.fromFile(destFile)
|
||||
}
|
||||
)
|
||||
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
|
||||
}
|
||||
Uri.fromFile(destFile)
|
||||
return GallerySaveResult(Uri.fromFile(destFile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +394,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 +407,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,34 +483,37 @@ 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)
|
||||
val baseUrl = baseUrlNode.textContent
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
// FIX: Only add prefix if it's not already a full URL
|
||||
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
|
||||
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
|
||||
}
|
||||
}
|
||||
|
||||
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 +538,7 @@ class DownloadProcessor (
|
||||
|
||||
dashPlaylistFile.delete()
|
||||
outputFile.delete()
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +558,7 @@ 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()) {
|
||||
@@ -439,113 +573,171 @@ class DownloadProcessor (
|
||||
return@launch
|
||||
}
|
||||
|
||||
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...")
|
||||
}
|
||||
|
||||
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 save only the base media (discard overlay)
|
||||
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
|
||||
val oldDownloadedMedias = downloadedMedias.toMap()
|
||||
|
||||
// Debug Stories: save raw ZIP to the configured debug folder
|
||||
val debugConfig = remoteSideContext.config.root.developer.debugStories
|
||||
if (debugConfig.enabled.get()) {
|
||||
val debugFolder = debugConfig.saveFolder.get().orEmpty().trim()
|
||||
if (debugFolder.isNotBlank()) {
|
||||
runCatching {
|
||||
val debugDir = android.net.Uri.parse(debugFolder).let { uri ->
|
||||
androidx.documentfile.provider.DocumentFile.fromTreeUri(remoteSideContext.androidContext, uri)
|
||||
}
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val debugFile = debugDir?.createFile("application/zip", "story_debug_${timestamp}.zip")
|
||||
debugFile?.uri?.let { fileUri ->
|
||||
remoteSideContext.androidContext.contentResolver.openOutputStream(fileUri)?.use { out ->
|
||||
zipFile.inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
remoteSideContext.log.warn("Debug Stories: failed to save zip: ${it.message}", "DownloadProcessor")
|
||||
}
|
||||
}.also {
|
||||
downloadedMedias[InputMedia(
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
content = it.absolutePath,
|
||||
isOverlay = type == SplitMediaAssetType.OVERLAY
|
||||
)] = DownloadedFile(it, FileType.fromFile(it))
|
||||
} else {
|
||||
remoteSideContext.log.warn("Debug Stories: enabled but no folder configured", "DownloadProcessor")
|
||||
}
|
||||
}
|
||||
|
||||
var baseMediaFile: File? = null
|
||||
zipFile.inputStream().use { zipFileInputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
|
||||
if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
|
||||
baseMediaFile = createMediaTempFile().apply {
|
||||
outputStream().use { inputStream.copyTo(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldDownloadedMedias.forEach { (_, value) -> value.delete() }
|
||||
|
||||
baseMediaFile?.let { baseMedia ->
|
||||
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
|
||||
baseMedia.delete()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
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 mediaFileType = FileType.fromFile(media)
|
||||
val overlayFileType = FileType.fromFile(overlayMedia)
|
||||
|
||||
val renamedMedia = renameFromFileType(media, mediaFileType)
|
||||
val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType)
|
||||
|
||||
if (mediaFileType.isImage && overlayFileType.isImage) {
|
||||
runCatching {
|
||||
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
|
||||
val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image")
|
||||
val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image")
|
||||
|
||||
val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap)
|
||||
val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg"))
|
||||
|
||||
val compressFormat = when (mediaFileType) {
|
||||
FileType.PNG -> Bitmap.CompressFormat.PNG
|
||||
FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
|
||||
else -> Bitmap.CompressFormat.JPEG
|
||||
}
|
||||
|
||||
mergedImage.outputStream().use {
|
||||
mergedBitmap.compress(compressFormat, 100, it)
|
||||
}
|
||||
|
||||
originalBitmap.recycle()
|
||||
overlayBitmap.recycle()
|
||||
mergedBitmap.recycle()
|
||||
|
||||
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
|
||||
mergedImage.delete()
|
||||
renamedOverlayMedia.delete()
|
||||
renamedMedia.delete()
|
||||
return@launch
|
||||
}.onFailure {
|
||||
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,12 @@ class FFMpegProcessor(
|
||||
)
|
||||
|
||||
|
||||
private val sharedExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
protected fun finalize() {
|
||||
runCatching { sharedExecutor.shutdown() }
|
||||
}
|
||||
|
||||
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
|
||||
val stringBuilder = StringBuilder()
|
||||
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
|
||||
@@ -106,7 +112,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 ->
|
||||
@@ -116,12 +133,7 @@ class FFMpegProcessor(
|
||||
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
|
||||
else -> return@logFunction
|
||||
}, log.message)
|
||||
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
|
||||
}
|
||||
|
||||
private fun isMediaCodecFailure(output: String): Boolean {
|
||||
val lower = output.lowercase()
|
||||
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
|
||||
}, { onStatistics(it) }, sharedExecutor)
|
||||
}
|
||||
|
||||
suspend fun execute(args: Request) {
|
||||
@@ -139,22 +151,27 @@ 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: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"
|
||||
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
|
||||
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
fun applyVideoArguments() {
|
||||
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
|
||||
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
|
||||
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
|
||||
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
|
||||
}
|
||||
|
||||
when (args.action) {
|
||||
Action.DOWNLOAD_DASH -> {
|
||||
applyVideoArguments()
|
||||
outputArguments += "-ss" to "'${args.startTime}ms'"
|
||||
if (args.duration != null) {
|
||||
outputArguments += "-t" to "'${args.duration}ms'"
|
||||
}
|
||||
}
|
||||
Action.MERGE_OVERLAY -> {
|
||||
applyVideoArguments()
|
||||
inputArguments += "-i" to args.overlay!!.absolutePath
|
||||
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
|
||||
}
|
||||
@@ -162,8 +179,9 @@ class FFMpegProcessor(
|
||||
if (ffmpegOptions.customAudioCodec.isEmpty()) {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
outputArguments -= "-c:v"
|
||||
args.videoCodec?.let {
|
||||
applyVideoArguments()
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to it
|
||||
} ?: run {
|
||||
outputArguments += "-vn"
|
||||
@@ -174,6 +192,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
}
|
||||
Action.MERGE_MEDIA -> {
|
||||
applyVideoArguments()
|
||||
inputArguments.clear()
|
||||
val filesInfo = args.inputs.mapNotNull { file ->
|
||||
runCatching {
|
||||
@@ -181,45 +200,47 @@ class FFMpegProcessor(
|
||||
}.getOrNull()?.let { file to it }
|
||||
}
|
||||
|
||||
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||
}?.let { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
} ?: throw Exception("Failed to get video size")
|
||||
try {
|
||||
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
|
||||
}?.let { (_, r) ->
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
|
||||
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
|
||||
} ?: throw Exception("Failed to get video size")
|
||||
|
||||
val filterFirstPart = StringBuilder()
|
||||
val filterSecondPart = StringBuilder()
|
||||
var containsNoSound = false
|
||||
val filterFirstPart = StringBuilder()
|
||||
val filterSecondPart = StringBuilder()
|
||||
var containsNoSound = false
|
||||
|
||||
filesInfo.forEachIndexed { index, (file, retriever) ->
|
||||
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
|
||||
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
|
||||
filterSecondPart.append("[v$index][$index:a]")
|
||||
} else {
|
||||
containsNoSound = true
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}]")
|
||||
filesInfo.forEachIndexed { index, (file, retriever) ->
|
||||
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
|
||||
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
|
||||
filterSecondPart.append("[v$index][$index:a]")
|
||||
} else {
|
||||
containsNoSound = true
|
||||
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
|
||||
}
|
||||
inputArguments += "-i" to file
|
||||
}
|
||||
inputArguments += "-i" to file
|
||||
|
||||
if (containsNoSound) {
|
||||
inputArguments += "-f" to "lavfi"
|
||||
inputArguments += "-t" to "0.1"
|
||||
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
|
||||
}
|
||||
|
||||
if (outputArguments["-c:a"] == "copy") {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
|
||||
outputArguments += "-map" to "[aout]"
|
||||
outputArguments += "-map" to "[vout]"
|
||||
} finally {
|
||||
filesInfo.forEach { it.second.close() }
|
||||
}
|
||||
|
||||
if (containsNoSound) {
|
||||
inputArguments += "-f" to "lavfi"
|
||||
inputArguments += "-t" to "0.1"
|
||||
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
|
||||
}
|
||||
|
||||
if (outputArguments["-c:a"] == "copy") {
|
||||
outputArguments -= "-c:a"
|
||||
}
|
||||
|
||||
outputArguments += "-fps_mode" to "vfr"
|
||||
|
||||
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
|
||||
outputArguments += "-map" to "\"[aout]\""
|
||||
outputArguments += "-map" to "\"[vout]\""
|
||||
|
||||
filesInfo.forEach { it.second.close() }
|
||||
}
|
||||
Action.DOWNLOAD_AUDIO_STREAM -> {
|
||||
outputArguments.clear()
|
||||
@@ -250,25 +271,11 @@ class FFMpegProcessor(
|
||||
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 += "-filter_complex" to filterParts.toString()
|
||||
outputArguments += "-map" to "[aout]"
|
||||
}
|
||||
}
|
||||
outputArguments += args.output.absolutePath
|
||||
try {
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
} catch (e: Exception) {
|
||||
val output = e.message.orEmpty()
|
||||
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
|
||||
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
|
||||
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
|
||||
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
|
||||
outputArguments -= "-c:v"
|
||||
outputArguments += "-c:v" to "libx264"
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
newFFMpegTask(globalArguments, inputArguments, outputArguments)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
|
||||
val job: Job
|
||||
val writePfd: ParcelFileDescriptor
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
|
||||
if (exists()) delete()
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class CallDownloadSessionImpl(
|
||||
val pendingTask = context.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Call Recording $author",
|
||||
title = context.translation.format("task_call_recording_title", "author" to author),
|
||||
author = author,
|
||||
hash = UUID.randomUUID().toString()
|
||||
)
|
||||
@@ -188,4 +188,4 @@ class CallDownloadSessionImpl(
|
||||
context.log.verbose("ending call")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -15,7 +17,11 @@ class AppDatabase(
|
||||
val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
lateinit var database: SQLiteDatabase
|
||||
|
||||
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
|
||||
// Multi-subscriber event stream for messaging data updates
|
||||
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
fun executeAsync(block: () -> Unit) {
|
||||
executor.execute {
|
||||
@@ -113,6 +119,22 @@ class AppDatabase(
|
||||
"id CHAR(36) PRIMARY KEY",
|
||||
"content TEXT",
|
||||
),
|
||||
"assistant_registry" to listOf(
|
||||
"id VARCHAR PRIMARY KEY",
|
||||
"kind VARCHAR",
|
||||
"title VARCHAR",
|
||||
"category VARCHAR",
|
||||
"path TEXT",
|
||||
"description TEXT",
|
||||
"settingKey VARCHAR",
|
||||
"screenRoute VARCHAR",
|
||||
"allowedActions TEXT",
|
||||
"allowedValues TEXT",
|
||||
"aliases TEXT",
|
||||
"commonTypos TEXT",
|
||||
"examples TEXT",
|
||||
"searchTokens TEXT",
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package me.eternal.purrfectsnap.storage
|
||||
|
||||
import android.content.ContentValues
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
import org.json.JSONArray
|
||||
|
||||
data class AssistantRegistryEntry(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val title: String,
|
||||
val category: String,
|
||||
val path: String,
|
||||
val description: String,
|
||||
val settingKey: String? = null,
|
||||
val screenRoute: String? = null,
|
||||
val allowedActions: List<String> = emptyList(),
|
||||
val allowedValues: List<String> = emptyList(),
|
||||
val aliases: List<String> = emptyList(),
|
||||
val commonTypos: List<String> = emptyList(),
|
||||
val examples: List<String> = emptyList(),
|
||||
val searchTokens: List<String> = emptyList()
|
||||
)
|
||||
|
||||
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
|
||||
|
||||
private fun parseStringList(raw: String?): List<String> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return runCatching {
|
||||
val array = JSONArray(raw)
|
||||
buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL("DELETE FROM assistant_registry")
|
||||
entries.forEach { entry ->
|
||||
database.insert(
|
||||
"assistant_registry",
|
||||
null,
|
||||
ContentValues().apply {
|
||||
put("id", entry.id)
|
||||
put("kind", entry.kind)
|
||||
put("title", entry.title)
|
||||
put("category", entry.category)
|
||||
put("path", entry.path)
|
||||
put("description", entry.description)
|
||||
put("settingKey", entry.settingKey)
|
||||
put("screenRoute", entry.screenRoute)
|
||||
put("allowedActions", entry.allowedActions.toJsonArrayString())
|
||||
put("allowedValues", entry.allowedValues.toJsonArrayString())
|
||||
put("aliases", entry.aliases.toJsonArrayString())
|
||||
put("commonTypos", entry.commonTypos.toJsonArrayString())
|
||||
put("examples", entry.examples.toJsonArrayString())
|
||||
put("searchTokens", entry.searchTokens.toJsonArrayString())
|
||||
}
|
||||
)
|
||||
}
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
|
||||
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
|
||||
val entries = mutableListOf<AssistantRegistryEntry>()
|
||||
while (cursor.moveToNext()) {
|
||||
entries += AssistantRegistryEntry(
|
||||
id = cursor.getStringOrNull("id") ?: continue,
|
||||
kind = cursor.getStringOrNull("kind") ?: "feature",
|
||||
title = cursor.getStringOrNull("title") ?: "",
|
||||
category = cursor.getStringOrNull("category") ?: "",
|
||||
path = cursor.getStringOrNull("path") ?: "",
|
||||
description = cursor.getStringOrNull("description") ?: "",
|
||||
settingKey = cursor.getStringOrNull("settingKey"),
|
||||
screenRoute = cursor.getStringOrNull("screenRoute"),
|
||||
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
|
||||
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
|
||||
aliases = parseStringList(cursor.getStringOrNull("aliases")),
|
||||
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
|
||||
examples = parseStringList(cursor.getStringOrNull("examples")),
|
||||
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
|
||||
)
|
||||
}
|
||||
entries
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.isStealthRule
|
||||
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
|
||||
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getInteger
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
|
||||
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
|
||||
@@ -86,6 +89,63 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceMessagingData(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
executeAsync {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
friends.forEach { friend ->
|
||||
// Industrial Filter: Only update existing friends, never auto-insert new ones.
|
||||
database.execSQL(
|
||||
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
|
||||
arrayOf<Any?>(
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId,
|
||||
friend.userId
|
||||
)
|
||||
)
|
||||
|
||||
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 ->
|
||||
// Industrial Filter: Only update existing groups.
|
||||
database.execSQL(
|
||||
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
|
||||
arrayOf<Any?>(
|
||||
group.name,
|
||||
group.participantsCount,
|
||||
group.conversationId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
// Notify all observers with the raw sync data (AddFriendDialog needs this)
|
||||
messagingDataFlow.tryEmit(friends to groups)
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
return database.rawQuery(
|
||||
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
|
||||
@@ -102,12 +162,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
context.log.error("Failed to parse rule", it)
|
||||
}
|
||||
}
|
||||
rules
|
||||
rules.normalizeStealthRules().toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
|
||||
executeAsync {
|
||||
val ruleType = MessagingRuleType.getByName(type)
|
||||
if (ruleType?.isStealthRule() == true) {
|
||||
val updatedStealthRules = getRules(targetUuid)
|
||||
.withNormalizedRuleToggle(ruleType, enabled)
|
||||
.filter { it.isStealthRule() }
|
||||
|
||||
database.beginTransaction()
|
||||
try {
|
||||
database.execSQL(
|
||||
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
|
||||
arrayOf(
|
||||
targetUuid,
|
||||
MessagingRuleType.STEALTH.key,
|
||||
MessagingRuleType.SNAP_STEALTH.key,
|
||||
MessagingRuleType.CHAT_STEALTH.key
|
||||
)
|
||||
)
|
||||
updatedStealthRules.forEach { stealthRule ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
|
||||
arrayOf(targetUuid, stealthRule.key)
|
||||
)
|
||||
}
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
return@executeAsync
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
|
||||
|
||||
@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
|
||||
.setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher))
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
@@ -46,7 +46,8 @@ data class Task(
|
||||
val type: TaskType,
|
||||
val title: String,
|
||||
val author: String?,
|
||||
val hash: String
|
||||
val hash: String,
|
||||
val isAutoOpen: Boolean = false
|
||||
) {
|
||||
var changeListener: () -> Unit = {}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ class RemoteTaskInterface(
|
||||
private val activeTasks = context.taskManager.getActiveTasks()
|
||||
|
||||
override fun createTask(type: String, title: String, author: String, hash: String): String {
|
||||
val taskType = TaskType.fromKey(type)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(type),
|
||||
type = taskType,
|
||||
title = title,
|
||||
author = author.takeIf { it.isNotBlank() },
|
||||
hash = hash
|
||||
hash = hash,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
context.taskManager.createPendingTask(task)
|
||||
return hash
|
||||
|
||||
@@ -38,11 +38,13 @@ class TaskManager(
|
||||
private val activeTasks = mutableMapOf<Long, PendingTask>()
|
||||
|
||||
private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
|
||||
val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(cursor.getStringOrNull("type")!!),
|
||||
type = taskType,
|
||||
title = cursor.getStringOrNull("title")!!,
|
||||
author = cursor.getStringOrNull("author"),
|
||||
hash = cursor.getStringOrNull("hash")!!
|
||||
hash = cursor.getStringOrNull("hash")!!,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
|
||||
task.extra = cursor.getStringOrNull("extra")
|
||||
@@ -127,15 +129,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>()
|
||||
|
||||
2420
app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/AI.kt
Normal file
@@ -46,6 +46,7 @@ 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
|
||||
|
||||
@@ -132,16 +133,16 @@ class MainActivity : ComponentActivity() {
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = "Wrong APK installed",
|
||||
title = managerContext.translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = "Close",
|
||||
confirmButtonText = managerContext.translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = "Your device is armv8, please download the armv8 apk, not armv7.",
|
||||
text = managerContext.translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
@@ -212,6 +213,23 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
navigation.NavContent(contentPadding, startDestination)
|
||||
|
||||
// Theme Reveal Overlay (Android 13+ only for stability)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
|
||||
CircularRevealOverlay(
|
||||
context = managerContext,
|
||||
request = revealRequest,
|
||||
onComplete = { navigation.themeRevealState.clearReveal() }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Instantly clear reveal state on older versions
|
||||
navigation.themeRevealState.pendingReveal?.let {
|
||||
navigation.themeRevealState.clearReveal()
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
|
||||
@@ -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) }
|
||||
@@ -369,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(
|
||||
@@ -430,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 = {
|
||||
@@ -441,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,
|
||||
@@ -456,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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -545,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) }
|
||||
@@ -577,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
|
||||
@@ -591,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)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -663,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)
|
||||
@@ -794,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>>()
|
||||
}
|
||||
@@ -54,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}"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ 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: RemoteSideContext): Fetch {
|
||||
fetch?.let { return it }
|
||||
@@ -84,6 +87,132 @@ object UpdateDownloader {
|
||||
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(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
@@ -100,6 +229,7 @@ object UpdateDownloader {
|
||||
networkType = NetworkType.ALL
|
||||
}
|
||||
listener?.let { fetch.removeListener(it) }
|
||||
var fallbackAttempted = false
|
||||
listener = object : AbstractFetchListener() {
|
||||
override fun onAdded(download: Download) {
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
@@ -116,60 +246,21 @@ object UpdateDownloader {
|
||||
}
|
||||
|
||||
override fun onCompleted(download: Download) {
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
runCatching {
|
||||
val downloadedFile = File(download.file)
|
||||
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
|
||||
}
|
||||
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,
|
||||
translation.format("update_download_failed_toast", "error" to error.toString()),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
|
||||
?: remoteContext.log.error("Update download failed: $error", TAG)
|
||||
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!!)
|
||||
|
||||
@@ -113,8 +113,8 @@ object Updater {
|
||||
|
||||
fun getLatestRelease(channel: Channel): LatestRelease? {
|
||||
return cache.getOrPut(channel) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
|
||||
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
|
||||
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
|
||||
} else {
|
||||
fetchLatestRelease(channel)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -52,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
|
||||
|
||||
@@ -136,10 +137,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
|
||||
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>() }
|
||||
|
||||
@@ -191,14 +196,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -210,7 +215,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
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 {
|
||||
|
||||
@@ -49,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") }
|
||||
@@ -65,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>()
|
||||
@@ -190,7 +232,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val importLabel = translation["confirm_button"] ?: "Import"
|
||||
val importLabel = translation["confirm_button"]
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -239,14 +281,14 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -256,12 +298,17 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
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"])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.GroupAdd
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -29,6 +25,7 @@ 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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -41,6 +38,7 @@ import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.storage.clearRuleIds
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.storage.setRule
|
||||
@@ -139,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
}
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
|
||||
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
|
||||
context.database.getRuleIds(currentRuleType.key)
|
||||
}
|
||||
val currentRuleIdSet = remember(currentRuleIds.size) {
|
||||
currentRuleIds.toSet()
|
||||
}
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -162,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
|
||||
fun showAddFriendDialog() {
|
||||
addFriendDialog = AddFriendDialog(
|
||||
context = context,
|
||||
pinnedIds = currentRuleIds,
|
||||
pinnedIds = currentRuleIds.toList(),
|
||||
actionHandler = Actions(
|
||||
onFriendState = { friend, state ->
|
||||
context.database.setRule(friend.userId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(friend.userId)
|
||||
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -175,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
currentRuleIdSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
currentRuleIdSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = translation["clear_list_button"],
|
||||
text = translation["dialog_clear_confirmation_text"],
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
confirmButtonText = context.translation["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)
|
||||
@@ -229,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
.zIndex(10f)
|
||||
.onGloballyPositioned {
|
||||
val newHeight = with(density) { it.size.height.toDp() }
|
||||
if (newHeight != topBarHeight) topBarHeight = newHeight
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.padding(top = topBarHeight + 10.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
item {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
shape = headerShape,
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()],
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay, headerShape)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"],
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == null,
|
||||
text = translation["disable_state_option"] ?: "Disabled",
|
||||
onStateChanged = { setRuleState(null) }
|
||||
) {
|
||||
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
val manageLabel = when (ruleState) {
|
||||
@@ -290,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.WHITELIST,
|
||||
text = translation["whitelist_state_option"] ?: "Whitelist",
|
||||
onStateChanged = { setRuleState(RuleState.WHITELIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
) {
|
||||
Text(text = translation["whitelist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"],
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"])
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
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.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = context.translation["clear"])
|
||||
Text(text = translation["whitelist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
item {
|
||||
SelectRuleTypeRadio(
|
||||
checked = ruleState == RuleState.BLACKLIST,
|
||||
text = translation["blacklist_state_option"] ?: "Blacklist",
|
||||
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Button(
|
||||
onClick = { showAddFriendDialog() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["blacklist_state_button"] ?: "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Surface(
|
||||
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.1f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
modifier = Modifier.size(46.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(CircleShape)
|
||||
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["clear_list_button"] ?: "Clear List",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!manageLabel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = manageLabel,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { confirmationDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +1,143 @@
|
||||
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.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.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.width
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
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.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.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.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.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import android.os.SystemClock
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
class HomeAbout : Routes.Route() {
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
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()
|
||||
}
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"] }
|
||||
val pagePadding = 16.dp
|
||||
val bottomPadding = routes.bottomPadding +
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"])
|
||||
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = bottomPadding)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = pagePadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.panelGradient)
|
||||
.padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_title"],
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontFamily = avenirNext,
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = tapSource,
|
||||
indication = null
|
||||
) {
|
||||
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 = 13.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = translation["about_lead_developers_title"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(top = 10.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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = pagePadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.padding(horizontal = 20.dp, vertical = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_story_title"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = aboutStory,
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = pagePadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
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(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_thanks_title"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
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)
|
||||
)
|
||||
) {
|
||||
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"], 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)
|
||||
) {
|
||||
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"], maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
key(themeId) {
|
||||
val currentTheme = ManagerTheme.fromId(themeId).theme
|
||||
with(currentTheme) {
|
||||
this@HomeAbout.AboutScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeveloperCard(
|
||||
internal fun DeveloperCard(
|
||||
name: String,
|
||||
imageRes: Int,
|
||||
avenirNext: FontFamily,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(20.dp)
|
||||
val imageRing = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary,
|
||||
PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = cardShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
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
|
||||
.height(150.dp)
|
||||
.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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 16.dp),
|
||||
.fillMaxSize()
|
||||
.padding(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(82.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.1f))
|
||||
.border(2.dp, imageRing, CircleShape)
|
||||
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()
|
||||
modifier = Modifier.fillMaxSize().clip(CircleShape)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = avenirNext,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
subtitle?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Report
|
||||
@@ -64,6 +65,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 +75,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 +91,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 +109,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(translation["read_logs_failed_toast"])
|
||||
}
|
||||
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(
|
||||
@@ -271,20 +168,20 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogsFloatingBar(
|
||||
internal fun LogsFloatingBar(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onFilter: () -> 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,
|
||||
@@ -292,87 +189,107 @@ 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)
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onFilter) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.FilterList,
|
||||
contentDescription = "Filter Logs",
|
||||
tint = PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,7 +298,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLogsState() {
|
||||
internal fun EmptyLogsState() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -412,7 +329,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,
|
||||
@@ -422,8 +339,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()
|
||||
@@ -534,14 +450,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"
|
||||
@@ -550,14 +466,38 @@ 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 {
|
||||
enum class LogCategory(val translationKey: String, val tags: List<String>) {
|
||||
CORE("log_category_core", listOf("core", "hook", "module", "mappings")),
|
||||
AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")),
|
||||
MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")),
|
||||
BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")),
|
||||
SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")),
|
||||
TRACKER("log_category_tracker", listOf("tracker", "friendtracker"))
|
||||
}
|
||||
|
||||
val enabledCategories = mutableStateMapOf<LogCategory, Boolean>().apply {
|
||||
LogCategory.entries.forEach { put(it, true) }
|
||||
}
|
||||
|
||||
internal fun getCategoryForLog(line: LogLine): LogCategory? {
|
||||
val tag = line.tag.lowercase()
|
||||
val message = line.message.lowercase()
|
||||
return LogCategory.entries.find { category ->
|
||||
category.tags.any { tag.contains(it) || message.contains("[$it]") }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun shouldHideLog(line: LogLine): Boolean {
|
||||
val category = getCategoryForLog(line)
|
||||
if (category != null && enabledCategories[category] == false) return true
|
||||
|
||||
val message = line.message.lowercase()
|
||||
val tag = line.tag.lowercase()
|
||||
return message.startsWith("blocked ep") ||
|
||||
|
||||
@@ -46,13 +46,13 @@ 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.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
@@ -82,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -92,6 +94,8 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.res.vectorResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -122,6 +126,9 @@ 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.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
|
||||
import me.eternal.purrfectsnap.ui.manager.data.Updater
|
||||
@@ -137,6 +144,7 @@ class HomeRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
|
||||
|
||||
companion object {
|
||||
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
|
||||
val cardMargin = 10.dp
|
||||
val pageBackgroundGradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -147,23 +155,23 @@ class HomeRootSection : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
private val changelogClient by lazy { OkHttpClient() }
|
||||
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
|
||||
private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
|
||||
private val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
|
||||
internal val changelogClient by lazy { OkHttpClient() }
|
||||
internal val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
|
||||
internal val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
|
||||
internal val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
|
||||
|
||||
private val heroGradientColors = listOf(
|
||||
internal val heroGradientColors = listOf(
|
||||
Color(0xFF5C4B99),
|
||||
Color(0xFF322B5E),
|
||||
Color(0xFF1B1836)
|
||||
)
|
||||
private val quickActionsGradientColors = listOf(
|
||||
internal val quickActionsGradientColors = listOf(
|
||||
Color(0xFF241C3E),
|
||||
Color(0xFF151127)
|
||||
)
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
|
||||
private val cardEntries by lazy {
|
||||
internal val cardEntries by lazy {
|
||||
val list = mutableListOf<QaCard>()
|
||||
EnumQuickActions.entries.forEach { q ->
|
||||
val name = context.translation["actions.${q.key}.name"]
|
||||
@@ -175,7 +183,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
list
|
||||
}
|
||||
private val cards by lazy {
|
||||
internal val cards by lazy {
|
||||
EnumQuickActions.entries.map {
|
||||
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
|
||||
}.associate {
|
||||
@@ -190,7 +198,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
|
||||
internal fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val state = remember { mutableStateOf(prefs.getBoolean(key, default)) }
|
||||
DisposableEffect(prefs, key) {
|
||||
@@ -213,13 +221,17 @@ class HomeRootSection : Routes.Route() {
|
||||
onClick: (() -> Unit)? = null,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
|
||||
haptic: HapticFeedback? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val clickModifier = if (onClick != null) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = LocalIndication.current
|
||||
) { onClick() }
|
||||
) {
|
||||
haptic?.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onClick()
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -243,7 +255,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroBadge(text: String) {
|
||||
internal fun HeroBadge(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
@@ -261,27 +273,31 @@ class HomeRootSection : Routes.Route() {
|
||||
icon: ImageVector,
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.height(36.dp),
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White)
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
label?.let {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -313,13 +329,20 @@ class HomeRootSection : Routes.Route() {
|
||||
|
||||
@Composable
|
||||
private fun RowScope.HomeActionChips() {
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.DEFAULT
|
||||
)
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"]
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
modifier = Modifier
|
||||
) { routes.homeLogs.navigate() }
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Info,
|
||||
label = translation["manager.routes.home_about"]
|
||||
label = translation["manager.routes.home_about"],
|
||||
modifier = Modifier
|
||||
) { routes.about.navigate() }
|
||||
}
|
||||
|
||||
@@ -408,7 +431,7 @@ class HomeRootSection : Routes.Route() {
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onWikiClick: () -> Unit,
|
||||
onWebsiteClick: () -> Unit,
|
||||
onTelegramClick: () -> Unit,
|
||||
onGithubClick: () -> Unit,
|
||||
authorName: String,
|
||||
@@ -623,7 +646,7 @@ class HomeRootSection : Routes.Route() {
|
||||
onClick = onManageClick,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
modifier = Alignment.CenterHorizontally.let { Modifier.align(it) }
|
||||
) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
@@ -649,15 +672,15 @@ class HomeRootSection : Routes.Route() {
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onWikiClick,
|
||||
onClick = onWebsiteClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1B152E)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["wiki_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -685,7 +708,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun resolveTileKey(name: String): String {
|
||||
internal fun resolveTileKey(name: String): String {
|
||||
val entry = cardEntries.firstOrNull { it.name == name }
|
||||
return entry?.id ?: name
|
||||
}
|
||||
@@ -703,13 +726,13 @@ class HomeRootSection : Routes.Route() {
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
|
||||
}
|
||||
private fun clearTileSpan(name: String) {
|
||||
internal fun clearTileSpan(name: String) {
|
||||
val prefs = context.sharedPreferences
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().remove("quick_tile_size_$key").apply()
|
||||
}
|
||||
|
||||
private fun clearTileOffset(name: String) {
|
||||
internal fun clearTileOffset(name: String) {
|
||||
val prefs = context.sharedPreferences
|
||||
val key = resolveTileKey(name)
|
||||
prefs.edit().remove("quick_tile_offset_$key").apply()
|
||||
@@ -723,606 +746,30 @@ class HomeRootSection : Routes.Route() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
HomeActionChips()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
}
|
||||
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
|
||||
context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
}
|
||||
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)) {
|
||||
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
|
||||
Updater.getLatestRelease(channel)
|
||||
}
|
||||
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
|
||||
var showChangelogDialog by remember { mutableStateOf(false) }
|
||||
var changelogLoading by remember { mutableStateOf(false) }
|
||||
var changelogError by remember { mutableStateOf<String?>(null) }
|
||||
var changelogText by remember { mutableStateOf<String?>(null) }
|
||||
var changelogVersion by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by remember { mutableStateOf(false) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
var announcementsError by remember { mutableStateOf<String?>(null) }
|
||||
var announcementsText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val handleUpdateAction: () -> Unit = {
|
||||
latestUpdate?.let { latest ->
|
||||
val supportedAbis = android.os.Build.SUPPORTED_ABIS
|
||||
var abiName: String? = null
|
||||
for (abi in supportedAbis) {
|
||||
when (abi) {
|
||||
"arm64-v8a" -> {
|
||||
abiName = "arm64"
|
||||
break
|
||||
}
|
||||
"armeabi-v7a" -> {
|
||||
abiName = "armv7"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
context.log.info(
|
||||
"Update request: device ABIs=${supportedAbis.joinToString()} resolvedArch=${abiName ?: "unknown"}",
|
||||
"HomeRoot"
|
||||
)
|
||||
|
||||
if (latest.workflowId != null) {
|
||||
if (abiName == null) {
|
||||
android.widget.Toast.makeText(
|
||||
context.androidContext,
|
||||
translation["update_arch_not_supported_toast"],
|
||||
android.widget.Toast.LENGTH_LONG
|
||||
).show()
|
||||
} else {
|
||||
val artifactName = "purrfectsnap-${abiName}-debug"
|
||||
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
|
||||
context.log.info("Debug update -> downloading $artifactName from $downloadUrl", "HomeRoot")
|
||||
UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope)
|
||||
}
|
||||
return@let
|
||||
}
|
||||
|
||||
val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] }
|
||||
if (releaseDownload != null) {
|
||||
val fileName = releaseDownload.substringAfterLast('/')
|
||||
context.log.info("Release update -> arch=$abiName url=$releaseDownload file=$fileName", "HomeRoot")
|
||||
UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope)
|
||||
} else {
|
||||
context.log.warn(
|
||||
"No matching update asset for arch=$abiName (available: ${latest.assetDownloads.keys})",
|
||||
"HomeRoot"
|
||||
)
|
||||
context.androidContext.openLink(
|
||||
latest.releaseUrl,
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChangelog(targetVersion: String, url: String) {
|
||||
if (changelogVersion == targetVersion && changelogText != null) return
|
||||
changelogLoading = true
|
||||
changelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty changelog body")
|
||||
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogText = text
|
||||
changelogVersion = targetVersion
|
||||
changelogLoading = false
|
||||
}
|
||||
}.onFailure { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
changelogError = error.message ?: "Failed to load changelog"
|
||||
changelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAnnouncements() {
|
||||
if (announcementsText != null) return
|
||||
announcementsLoading = true
|
||||
announcementsError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
changelogClient.newCall(Request.Builder().url(announcementsUrl).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch announcements (${response.code})")
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty announcements body")
|
||||
body.trim()
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
announcementsText = text
|
||||
announcementsLoading = false
|
||||
}
|
||||
}.onFailure { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
announcementsError = error.message ?: "Failed to load announcements"
|
||||
announcementsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
|
||||
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
|
||||
context.sharedPreferences.edit()
|
||||
.putBoolean("show_changelog_on_launch", false)
|
||||
.remove("changelog_version_on_launch")
|
||||
.apply()
|
||||
version?.let {
|
||||
showChangelogDialog = true
|
||||
loadChangelog(it, changelogUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (context.sharedPreferences.getBoolean("show_announcements_on_launch", false)) {
|
||||
context.sharedPreferences.edit()
|
||||
.putBoolean("show_announcements_on_launch", false)
|
||||
.apply()
|
||||
showAnnouncementsDialog = true
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
val onUpdateButtonClick: () -> Unit = {
|
||||
latestUpdate?.let {
|
||||
showChangelogDialog = true
|
||||
loadChangelog(it.versionName, changelogUrl)
|
||||
}
|
||||
}
|
||||
|
||||
var showQuickActionsMenu by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val contentBottomPadding = routes.bottomPadding + navigationBarPadding + 96.dp
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(pageBackgroundGradient)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = contentBottomPadding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(WindowInsets.statusBars.asPaddingValues())
|
||||
.padding(horizontal = cardMargin, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = null,
|
||||
contentDescription = translation["announcements_button_description"]
|
||||
) {
|
||||
showAnnouncementsDialog = true
|
||||
loadAnnouncements()
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(Alignment.End),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HomeActionChips()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HeroSection(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
latestUpdate = latestUpdate,
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = onUpdateButtonClick,
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onWikiClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap/wiki",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
onTelegramClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://t.me/purrfectsnap_official",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
onGithubClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
authorName = "ETERNAL",
|
||||
onManageClick = { routes.settings.navigate() },
|
||||
avenirNext = avenirNext,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsAnim") { hasQuickActions ->
|
||||
val quickCardShape = RoundedCornerShape(34.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = cardMargin, vertical = 10.dp),
|
||||
shape = quickCardShape,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 24.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)
|
||||
.padding(bottom = navigationBarPadding + 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (!hasQuickActions) {
|
||||
Text(
|
||||
translation["quick_actions_title"],
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Widgets,
|
||||
contentDescription = translation["quick_actions_icon_description"],
|
||||
modifier = Modifier.size(72.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = translation["quick_actions_empty_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = translation["quick_actions_empty_subtitle"],
|
||||
fontSize = 14.sp,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Button(
|
||||
onClick = { showQuickActionsMenu = true },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1B152E)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = translation["add_quick_action_description"],
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = translation["quick_actions_add_tile_button"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
translation["quick_actions_title"],
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Clip
|
||||
)
|
||||
Text(
|
||||
text = translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()),
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { 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 = translation["manage_quick_actions_description"],
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = translation["quick_actions_manage_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
val spacing = 12.dp
|
||||
val gridPadding = 8.dp
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val preferredTileWidth = 100.dp
|
||||
val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing))
|
||||
.toInt()
|
||||
.coerceAtLeast(2)
|
||||
.coerceAtMost(4)
|
||||
val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns
|
||||
val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = gridPadding),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
maxItemsInEachRow = columns
|
||||
) {
|
||||
selectedTiles.forEach { tileName ->
|
||||
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach
|
||||
val (card, action) = cardEntry
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(tileWidth)
|
||||
.aspectRatio(1.05f)
|
||||
.scaleOnPress(interactionSource)
|
||||
.clickable { action(routes) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
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(all = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = card.second, contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = card.first,
|
||||
lineHeight = 16.sp,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showChangelogDialog && latestUpdate != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Update",
|
||||
onConfirm = {
|
||||
showChangelogDialog = false
|
||||
handleUpdateAction()
|
||||
},
|
||||
dismissButtonText = "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
confirmEnabled = !changelogLoading,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
when {
|
||||
changelogLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Loading changelog…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
changelogError != null -> {
|
||||
Text(
|
||||
text = changelogError ?: "Failed to load changelog",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = changelogText ?: "Changelog not available",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAnnouncementsDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showAnnouncementsDialog = false },
|
||||
title = "Announcements",
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Close",
|
||||
onConfirm = { showAnnouncementsDialog = false },
|
||||
confirmEnabled = !announcementsLoading,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
when {
|
||||
announcementsLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Loading announcements...",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
announcementsError != null -> {
|
||||
Text(
|
||||
text = announcementsError ?: "Failed to load announcements",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = announcementsText ?: "Announcements not available",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
selectedQuickActions = selectedTiles,
|
||||
onDismiss = { showQuickActionsMenu = false },
|
||||
onSave = { newList ->
|
||||
val previous = selectedTiles.toList()
|
||||
val removed = previous.filter { it !in newList }
|
||||
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
|
||||
newList.forEach { clearTileOffset(it) }
|
||||
selectedTiles.clear()
|
||||
selectedTiles.addAll(newList)
|
||||
context.coroutineScope.launch {
|
||||
context.database.setQuickTiles(selectedTiles)
|
||||
}
|
||||
showQuickActionsMenu = false
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@HomeRootSection.HomeScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
internal fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
val lines = raw.lines()
|
||||
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
|
||||
val collected = mutableListOf<String>()
|
||||
@@ -1341,3 +788,7 @@ private fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ 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
|
||||
@@ -32,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
|
||||
@@ -43,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
|
||||
@@ -51,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
|
||||
@@ -65,40 +69,15 @@ 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)
|
||||
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()
|
||||
}
|
||||
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
|
||||
|
||||
if (autoUpdateCheck) {
|
||||
val repeatInterval = when (frequency) {
|
||||
"daily" -> 1L
|
||||
"weekly" -> 7L
|
||||
"monthly" -> 30L
|
||||
else -> 1L
|
||||
}
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
@@ -108,10 +87,10 @@ 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)
|
||||
.putString("update_channel", "stable")
|
||||
.build()
|
||||
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
|
||||
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
@@ -125,11 +104,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),
|
||||
@@ -138,16 +137,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)) {
|
||||
@@ -156,6 +161,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()
|
||||
@@ -164,11 +187,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
|
||||
@@ -184,7 +211,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
|
||||
@@ -216,7 +243,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)
|
||||
}
|
||||
@@ -264,8 +291,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,
|
||||
@@ -278,643 +306,52 @@ class HomeSettings : Routes.Route() {
|
||||
) { content(this) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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"])
|
||||
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()
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["use_system_toasts_label"])
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"])
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
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) {
|
||||
val spacingModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 26.dp)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = channelMenuExpanded,
|
||||
onExpandedChange = { channelMenuExpanded = it },
|
||||
modifier = spacingModifier
|
||||
) {
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = "Reset PurrfectSnap")
|
||||
ShiftedRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp)
|
||||
.clickable {
|
||||
// Clear setup progress and route back to SetupActivity
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
|
||||
// Clear config to defaults
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
|
||||
// Launch setup activity fresh
|
||||
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
|
||||
// Close current manager activity
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Reset and restart setup",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "Reset",
|
||||
modifier = Modifier.padding(end = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
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 { 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(
|
||||
translation.format(
|
||||
"open_file_failed_toast",
|
||||
"message" to (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"])
|
||||
}
|
||||
Button(
|
||||
onClick = { showImportDialog = true },
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = "Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = { Text("Import message logger") },
|
||||
text = { Text("Importing will override your current message logger database. Continue?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
context.messageLogger.databaseFile.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open selected file")
|
||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||
context.shortToast(translation["success_toast"])
|
||||
context.log.info("Imported message logger from $uri", "MessageLogger")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import message logger", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to launch import picker", it)
|
||||
context.longToast(
|
||||
translation.format(
|
||||
"import_failed_toast",
|
||||
"message" to (it.localizedMessage ?: it.message ?: "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(translation["button.import"] ?: "Import")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportDialog = false }) {
|
||||
Text(translation["button.cancel"])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)
|
||||
) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -19,22 +20,15 @@ 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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
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.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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
|
||||
@@ -44,6 +38,7 @@ 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
|
||||
@@ -58,6 +53,8 @@ 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
|
||||
@@ -194,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -205,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
|
||||
.padding(bottom = bottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
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
|
||||
@@ -243,7 +282,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = "GAME OVER",
|
||||
text = translation["game_over_label"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -258,7 +297,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
|
||||
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +332,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "LEFT",
|
||||
text = translation["left_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
@@ -324,7 +363,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "RIGHT",
|
||||
text = translation["right_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
|
||||
@@ -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 = 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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,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
|
||||
}
|
||||
@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("script")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
@@ -292,7 +293,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"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,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(
|
||||
@@ -311,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)
|
||||
}
|
||||
@@ -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,84 +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(),
|
||||
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
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@ScriptingRootSection.ScriptingScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledTabContent(
|
||||
internal fun InstalledTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val scriptModules by rememberAsyncMutableState(
|
||||
@@ -627,7 +569,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
.heightIn(min = 260.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
@@ -743,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,
|
||||
@@ -799,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTabContent(
|
||||
internal fun CatalogTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -832,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingHeader(
|
||||
internal fun ScriptingHeader(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -875,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
|
||||
@@ -910,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingTabSwitcher(
|
||||
internal fun ScriptingTabSwitcher(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -957,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.*
|
||||
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.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
import me.eternal.purrfectsnap.storage.getGroups
|
||||
import me.eternal.purrfectsnap.storage.getRuleIds
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
|
||||
|
||||
@@ -219,35 +221,68 @@ 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
|
||||
}
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else groups
|
||||
}
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
|
||||
runCatching {
|
||||
context.androidContext.sendBroadcast(it)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to send broadcast", it)
|
||||
hasFetchError = true
|
||||
fun applySnapshot(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
|
||||
val sortedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} else {
|
||||
// Priority sort for whitelisted groups
|
||||
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
|
||||
sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.conversationId)
|
||||
val bSelected = whitelistedIds.contains(b.conversationId)
|
||||
if (aSelected != bSelected) if (aSelected) -1 else 1
|
||||
else a.name.compareTo(b.name, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
cachedFriends = sortedFriends
|
||||
cachedGroups = sortedGroups
|
||||
if (friends.isNotEmpty() || groups.isNotEmpty()) {
|
||||
timeoutJob?.cancel()
|
||||
hasFetchError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial database load
|
||||
withContext(Dispatchers.IO) {
|
||||
applySnapshot(
|
||||
context.database.getFriends(descOrder = true),
|
||||
context.database.getGroups()
|
||||
)
|
||||
}
|
||||
|
||||
// Real-time synchronization flow
|
||||
context.requestSocialSnapshotRefresh()
|
||||
coroutineScope.launch {
|
||||
context.database.messagingDataFlow.collect { (friends, groups) ->
|
||||
applySnapshot(friends, groups)
|
||||
}
|
||||
}
|
||||
|
||||
timeoutJob = coroutineScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
delay(20000)
|
||||
hasFetchError = true
|
||||
delay(25000)
|
||||
if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) {
|
||||
hasFetchError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(
|
||||
onDismissRequest = {
|
||||
@@ -305,6 +340,7 @@ 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 ->
|
||||
@@ -315,6 +351,16 @@ class AddFriendDialog(
|
||||
val hasFriendsSelected = selectedFriendCount > 0
|
||||
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
|
||||
|
||||
val selectedGroupCount by remember(filteredGroups) {
|
||||
derivedStateOf {
|
||||
filteredGroups.count { group ->
|
||||
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
val hasGroupsSelected = selectedGroupCount > 0
|
||||
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
|
||||
|
||||
DialogHeader(searchKeyword)
|
||||
|
||||
LazyColumn(
|
||||
@@ -324,14 +370,54 @@ class AddFriendDialog(
|
||||
) {
|
||||
item {
|
||||
if (filteredGroups.isNotEmpty()) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp, top = 8.dp),
|
||||
color = Color.White
|
||||
)
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = translation["category_groups"],
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = true
|
||||
actionHandler.onGroupState(group, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !allGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
filteredGroups.forEach { group ->
|
||||
stateCache[group.conversationId] = false
|
||||
actionHandler.onGroupState(group, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = hasGroupsSelected
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,13 +461,8 @@ class AddFriendDialog(
|
||||
enabled = !allFriendsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
|
||||
?: "Select All",
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
PurrfectPalette.glowSecondary
|
||||
}
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
@@ -397,11 +478,7 @@ class AddFriendDialog(
|
||||
) {
|
||||
Text(
|
||||
text = translation["unselect_all_button"],
|
||||
color = if (hasFriendsSelected) {
|
||||
PurrfectPalette.glowPrimary
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
}
|
||||
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
|
||||
class LoggedStories : Routes.Route() {
|
||||
override val title: @Composable () -> Unit = {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val text = remember(navBackStackEntry) {
|
||||
navBackStackEntry?.arguments?.getString("id")?.let {
|
||||
context.database.getFriendInfo(it)?.displayName
|
||||
}
|
||||
val userId = navBackStackEntry?.arguments?.getString("id")
|
||||
val displayName by rememberAsyncMutableState(defaultValue = null) {
|
||||
userId?.let { context.database.getFriendInfo(it)?.displayName }
|
||||
}
|
||||
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
|
||||
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
|
||||
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
|
||||
|
||||
val stories = remember { mutableStateListOf<StoryData>() }
|
||||
val friendInfo = remember { context.database.getFriendInfo(userId) }
|
||||
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
|
||||
context.database.getFriendInfo(userId)
|
||||
}
|
||||
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
|
||||
|
||||
var selectedStory by remember { mutableStateOf<StoryData?>(null) }
|
||||
|
||||
@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
|
||||
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
|
||||
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
|
||||
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
@@ -91,7 +93,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 = {
|
||||
@@ -109,7 +111,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 }) {
|
||||
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val rules = rememberAsyncMutableStateList(listOf()) {
|
||||
context.database.getRules(id)
|
||||
context.database.getRules(id).normalizeStealthRules().toList()
|
||||
}
|
||||
|
||||
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
|
||||
val previousRules = rules.toSet()
|
||||
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
|
||||
val changedRules = (previousRules + updatedRules).filter { rule ->
|
||||
(rule in previousRules) != (rule in updatedRules)
|
||||
}
|
||||
|
||||
rules.clear()
|
||||
rules.addAll(updatedRules)
|
||||
|
||||
changedRules.forEach { changedRule ->
|
||||
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
|
||||
}
|
||||
}
|
||||
|
||||
SectionTitle(translation["rules_title"])
|
||||
|
||||
ContentCard {
|
||||
MessagingRuleType.entries.forEach { ruleType ->
|
||||
var ruleEnabled by remember(rules.size) {
|
||||
mutableStateOf(rules.any { it.key == ruleType.key })
|
||||
}
|
||||
|
||||
val ruleState = context.config.root.rules.getRuleState(ruleType)
|
||||
val ruleEnabled = rules.any { it.key == ruleType.key }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
|
||||
checked = ruleEnabled,
|
||||
enabled = if (ruleType.listMode) ruleState != null else true,
|
||||
onCheckedChange = {
|
||||
context.database.setRule(id, ruleType.key, it)
|
||||
ruleEnabled = it
|
||||
updateRules(ruleType, it)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.social
|
||||
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.storage.getFriends
|
||||
|
||||
internal fun RemoteSideContext.sortSocialFriends(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
pinnedIds: List<String>? = null
|
||||
): List<MessagingFriendInfo> {
|
||||
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
|
||||
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
|
||||
return friends.sortedWith { a, b ->
|
||||
val aSelected = whitelistedIds.contains(a.userId)
|
||||
val bSelected = whitelistedIds.contains(b.userId)
|
||||
|
||||
if (aSelected != bSelected) {
|
||||
return@sortedWith if (aSelected) -1 else 1
|
||||
}
|
||||
|
||||
if (sortByStreakLength) {
|
||||
val aStreak = a.streaks?.length ?: 0
|
||||
val bStreak = b.streaks?.length ?: 0
|
||||
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
|
||||
}
|
||||
|
||||
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,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.launch
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
|
||||
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
|
||||
@@ -44,22 +44,49 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
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.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() {
|
||||
context.coroutineScope.launch {
|
||||
friendList = context.database.getFriends(descOrder = true)
|
||||
groupList = context.database.getGroups()
|
||||
@Composable
|
||||
fun SocialDataController() {
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial data fetch from the database
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time synchronization from the bridge
|
||||
context.database.messagingDataFlow.collect {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dbFriends = context.database.getFriends(descOrder = true)
|
||||
val dbGroups = context.database.getGroups()
|
||||
val sortedFriends = context.sortSocialFriends(dbFriends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = dbGroups
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestLatestSnapshot() {
|
||||
context.requestSocialSnapshotRefresh()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScopeList(
|
||||
internal fun ScopeList(
|
||||
scope: SocialScope,
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
@@ -76,7 +103,6 @@ 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 = list.size
|
||||
|
||||
if (listSize == 0) {
|
||||
@@ -119,11 +145,6 @@ class SocialRootSection : Routes.Route() {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
@@ -153,8 +174,7 @@ class SocialRootSection : Routes.Route() {
|
||||
},
|
||||
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
|
||||
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
|
||||
),
|
||||
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -185,137 +205,23 @@ 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 }
|
||||
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) }
|
||||
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,
|
||||
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"],
|
||||
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)
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@SocialRootSection.SocialScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialCard(
|
||||
internal fun SocialCard(
|
||||
scope: SocialScope,
|
||||
friend: MessagingFriendInfo?,
|
||||
group: MessagingGroupInfo?,
|
||||
@@ -478,7 +384,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialHeader(
|
||||
internal fun SocialHeader(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -520,7 +426,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
|
||||
@@ -552,7 +458,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocialTabSwitcher(
|
||||
internal fun SocialTabSwitcher(
|
||||
titles: List<String>,
|
||||
pagerState: androidx.compose.foundation.pager.PagerState,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -598,7 +504,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"]
|
||||
@@ -625,13 +531,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
|
||||
)
|
||||
@@ -640,7 +546,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),
|
||||
@@ -669,3 +575,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "RSR", subtitle = "", 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,891 @@
|
||||
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.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.ManagerAssistantEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
|
||||
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,
|
||||
modifier: Modifier = Modifier,
|
||||
expandedWidth: Dp? = null,
|
||||
collapsedWidth: Dp = 36.dp,
|
||||
haptic: HapticFeedback,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
|
||||
Surface(
|
||||
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
|
||||
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.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.45f) / 0.55f).coerceIn(0f, 1f)
|
||||
if (labelAlpha > 0.02f) {
|
||||
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.Ellipsis,
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
|
||||
.weight(1f, fill = false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
}
|
||||
ManagerAssistantEntry(
|
||||
context = context,
|
||||
routes = routes,
|
||||
style = ManagerAssistantTriggerStyle.APHELION,
|
||||
shrinkFactor = shrinkFactor,
|
||||
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
|
||||
)
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.BugReport,
|
||||
label = context.translation["manager.routes.home_logs"],
|
||||
expandedWidth = 88.dp,
|
||||
shrinkFactor = shrinkFactor, haptic = haptic
|
||||
) { routes.homeLogs.navigate() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Settings,
|
||||
label = context.translation["manager.routes.home_settings"],
|
||||
expandedWidth = 96.dp,
|
||||
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,
|
||||
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))
|
||||
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.coroutineScope.launch(Dispatchers.IO) {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
}
|
||||
allQuickTileNames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
|
||||
Updater.getLatestRelease(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 showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError 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 = 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog() {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = changelogStableUrl
|
||||
runCatching {
|
||||
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
|
||||
body.trim()
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogText = text
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogError = e.message ?: "Failed to fetch"
|
||||
fullChangelogLoading = 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)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.wrapContentWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
expandedWidth = 52.dp,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
|
||||
haptic = haptic
|
||||
) { showFullChangelogDialog = true; loadFullChangelog() }
|
||||
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() } },
|
||||
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 },
|
||||
showCloseButton = 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 },
|
||||
showCloseButton = 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 (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: 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,256 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.lazy.items
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
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.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
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) }
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
isRefreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val readerResult = runCatching {
|
||||
context.log.newReader { /* items are batch-added from reader logic below */ }
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
|
||||
withContext(Dispatchers.Main) { isRefreshing = false }
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogFilterDialog() {
|
||||
Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(
|
||||
title = translation["filter_logs_title"] ?: "Log Filters",
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
enabledCategories[category] = !(enabledCategories[category] ?: true)
|
||||
refreshLogs()
|
||||
}
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.3f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
modifier = Modifier.fillMaxWidth().height(54.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterDialog) {
|
||||
LogFilterDialog()
|
||||
}
|
||||
|
||||
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 = { showFilterDialog = true }) {
|
||||
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
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,180 @@
|
||||
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.delay
|
||||
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.pages.social.sortSocialFriends
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
// Controller handles data loading and synchronization
|
||||
SocialDataController()
|
||||
|
||||
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) }
|
||||
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
|
||||
// Filter logic based on the parent's synchronized data lists
|
||||
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,755 @@
|
||||
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.manager.pages.TasksRootSection.TaskTab
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@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 scrollOffset = routes.navigation?.globalScrollOffset ?: 0
|
||||
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = containerTopPadding),
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, 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))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TaskTab.entries.forEach { tab ->
|
||||
val isSelected = selectedTab == tab
|
||||
val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(38.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.White.copy(alpha = backgroundAlpha))
|
||||
.clickable {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
selectedTab = tab
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"),
|
||||
color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND }
|
||||
val recentList = recentTasks.filter { task ->
|
||||
task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash }
|
||||
}
|
||||
|
||||
val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND }
|
||||
val scheduledRecent = recentTasks.filter { task ->
|
||||
task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = 8.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (selectedTab == TaskTab.ACTIVE) {
|
||||
item(key = "auto_open_card") {
|
||||
var queueItems by remember { mutableStateOf(listOf<Any>()) }
|
||||
var processedCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
runCatching {
|
||||
val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface
|
||||
processedCount = autoOpen?.processedCount ?: 0
|
||||
val items = autoOpen?.queueItems ?: emptyList()
|
||||
queueItems = items.mapNotNull {
|
||||
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
|
||||
}
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
val queueSize = queueItems.size
|
||||
|
||||
if (queueSize > 0 || processedCount > 0) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
null,
|
||||
tint = Color.White.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) {
|
||||
Text(
|
||||
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
queueItems.forEach { rawItem ->
|
||||
val item = rawItem as? Map<String, String> ?: return@forEach
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
|
||||
Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f))
|
||||
}
|
||||
Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0 || queueSize > 0) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(36.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f))
|
||||
) {
|
||||
Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeList.isEmpty() && recentList.isEmpty()) {
|
||||
item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") }
|
||||
}
|
||||
|
||||
val groupedActiveTasks = activeList.distinctBy { it.task.hash }
|
||||
|
||||
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
|
||||
val isAutoOpenTask = pendingTask.task.isAutoOpen
|
||||
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseAlpha by pulseAnimation.animateFloat(
|
||||
initialValue = 0.15f,
|
||||
targetValue = 0.45f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "alpha"
|
||||
)
|
||||
|
||||
AphelionTaskCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (isAutoOpenTask) {
|
||||
it.border(
|
||||
width = 1.5.dp,
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
} else it
|
||||
},
|
||||
task = pendingTask.task,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
}
|
||||
|
||||
items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
} else {
|
||||
if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) {
|
||||
item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") }
|
||||
}
|
||||
|
||||
items(scheduledActive, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(scheduledRecent, 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 = {
|
||||
if (taskSelection.size > 1) {
|
||||
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||
}
|
||||
|
||||
if (canMergeSelection) {
|
||||
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["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfirmDialog) {
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
} else {
|
||||
translation["tasks_remove_all_tasks_confirm"]
|
||||
}
|
||||
val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_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 {
|
||||
val labelText = if (task.isAutoOpen) {
|
||||
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0
|
||||
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
|
||||
"$it • $speed snaps/min"
|
||||
} else it
|
||||
Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White)
|
||||
}
|
||||
} else {
|
||||
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
|
||||
}
|
||||
if (taskProgress != -1 && (taskProgressLabel == null || isActive)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
|
||||
@@ -25,13 +25,7 @@ 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,
|
||||
@@ -304,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!!)
|
||||
@@ -347,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()
|
||||
@@ -568,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
|
||||
@@ -625,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
|
||||
@@ -796,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
|
||||
|
||||
@@ -245,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
|
||||
}
|
||||
@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val repositories by remember(refreshTrigger.value) {
|
||||
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
|
||||
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(refreshTrigger.value) {
|
||||
repositories = context.database.getRepositories("friend_tracker")
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
@@ -282,7 +283,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,111 @@
|
||||
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.*
|
||||
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
|
||||
|
||||
// "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
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
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"
|
||||
)
|
||||
|
||||
// --- AGSL SHADER LOGIC (Android 13+) ---
|
||||
|
||||
val runtimeShader = remember(bitmap) {
|
||||
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
|
||||
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
|
||||
}
|
||||
}
|
||||
|
||||
val shaderPaint = remember(runtimeShader) {
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = runtimeShader
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radius = animatedRadius.value
|
||||
val center = request.originCenter
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
|
||||
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.SmartToy
|
||||
import androidx.compose.material.icons.filled.VerifiedUser
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -93,6 +94,8 @@ 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.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -104,6 +107,8 @@ 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.setup.screens.impl.IntroShowcaseScreen
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
|
||||
val setupPrefs = setupContext.sharedPreferences
|
||||
val setupRoutes = Routes(setupContext).apply {
|
||||
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
|
||||
}
|
||||
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
|
||||
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
|
||||
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
|
||||
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
|
||||
|
||||
val requiredScreens = mutableListOf<SetupScreen>().apply {
|
||||
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
|
||||
add(IntroShowcaseScreen().apply { route = "introShowcase" })
|
||||
add(PickLanguageScreen().apply { route = "language" })
|
||||
if (isFirstRunFlow) {
|
||||
add(InstallModeScreen(
|
||||
@@ -315,19 +324,7 @@ 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--
|
||||
}
|
||||
}
|
||||
}
|
||||
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -344,46 +341,8 @@ 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()
|
||||
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
|
||||
val bottomPadding = 118.dp + navBarPadding
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 32.dp)
|
||||
)
|
||||
setupAiPrompt?.let { prompt ->
|
||||
ManagerAssistantDialog(
|
||||
context = setupContext,
|
||||
routes = setupRoutes,
|
||||
initialUserMessage = prompt,
|
||||
onDismiss = { setupAiPrompt = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
|
||||
subtitle = translation["setup.activity.language_subtitle"],
|
||||
icon = Icons.Filled.Language
|
||||
)
|
||||
is IntroShowcaseScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Welcome",
|
||||
subtitle = "Preview what PurrfectSnap can do",
|
||||
icon = Icons.Filled.AutoAwesome
|
||||
)
|
||||
|
||||
is InstallModeScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SetupTopBar() {
|
||||
private fun SetupTopBar(onAskAi: () -> Unit) {
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "PurrfectSnap",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(40),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(40))
|
||||
.clickable(onClick = onAskAi)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
|
||||
Text(
|
||||
text = "Ask AI",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package me.eternal.purrfectsnap.ui.setup.screens.impl
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
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.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.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.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.layout.ContentScale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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 kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
|
||||
class IntroShowcaseScreen : SetupScreen() {
|
||||
private val slides = listOf(
|
||||
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
|
||||
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
|
||||
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
LaunchedEffect(Unit) { allowNext(true) }
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(5000)
|
||||
currentIndex = (currentIndex + 1) % slides.size
|
||||
}
|
||||
}
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Welcome to PurrfectSnap",
|
||||
subtitle = "A quick look before setup begins",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(slides[index].first),
|
||||
contentDescription = slides[index].second,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = slides[index].second,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
slides.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 10.dp else 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,14 @@ 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
|
||||
@@ -34,15 +27,11 @@ 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
|
||||
@@ -55,16 +44,7 @@ class MappingsScreen : SetupScreen() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
fun finishMappings() = goNext()
|
||||
|
||||
if (infoText != null) {
|
||||
fun dismiss() {
|
||||
@@ -87,103 +67,6 @@ 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
|
||||
|
||||
@@ -74,6 +74,8 @@ 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.ManagerAssistantDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
var installWatcher by remember { mutableStateOf<Job?>(null) }
|
||||
var downloadFinished by rememberSaveable { mutableStateOf(false) }
|
||||
var showIssuesDialog by remember { mutableStateOf(false) }
|
||||
val assistantRoutes = remember {
|
||||
Routes(context).apply {
|
||||
}
|
||||
}
|
||||
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
@@ -313,62 +319,12 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
ManagerAssistantDialog(
|
||||
context = context,
|
||||
routes = assistantRoutes,
|
||||
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
|
||||
showImprovementLogging = false,
|
||||
onDismiss = { showIssuesDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,16 @@ 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(
|
||||
@@ -104,7 +114,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
|
||||
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -26,9 +28,12 @@ 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.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -40,7 +45,6 @@ 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
|
||||
import com.github.skydoves.colorpicker.compose.*
|
||||
import com.google.gson.JsonParser
|
||||
@@ -57,19 +61,171 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
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
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
|
||||
|
||||
|
||||
class AlertDialogs(
|
||||
private val translation: LocaleWrapper,
|
||||
){
|
||||
@Composable
|
||||
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
|
||||
val currentValue = property.value.getNullable()?.toString() ?: "[]"
|
||||
val propertyName = translation[property.key.propertyName()]
|
||||
|
||||
MessageListManagerDialog(
|
||||
title = propertyName ?: "",
|
||||
messageListJson = currentValue,
|
||||
onSave = { newValue: String ->
|
||||
property.value.setAny(newValue)
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AutoOpenScheduleDialog(
|
||||
property: PropertyPair<String>,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val windowParts = (property.value.get() as String).split("-")
|
||||
val startTime = windowParts.getOrNull(0)?.split(":") ?: listOf("23", "00")
|
||||
val endTime = windowParts.getOrNull(1)?.split(":") ?: listOf("07", "00")
|
||||
|
||||
var isEditingEnd by remember { mutableStateOf(false) }
|
||||
|
||||
val startState = rememberTimePickerState(
|
||||
initialHour = startTime.getOrNull(0)?.toIntOrNull() ?: 23,
|
||||
initialMinute = startTime.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
is24Hour = true
|
||||
)
|
||||
val endState = rememberTimePickerState(
|
||||
initialHour = endTime.getOrNull(0)?.toIntOrNull() ?: 7,
|
||||
initialMinute = endTime.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
is24Hour = true
|
||||
)
|
||||
|
||||
DefaultDialogCard {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["auto_open_snaps.auto_open_schedule.title"] ?: "Auto Open Scheduler",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val activeColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
|
||||
val inactiveColor = Color.Transparent
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (!isEditingEnd) activeColor else inactiveColor)
|
||||
.clickable { isEditingEnd = false }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${translation["auto_open_snaps.auto_open_schedule.start"] ?: "Start"}: ${String.format("%02d:%02d", startState.hour, startState.minute)}",
|
||||
color = if (!isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
fontWeight = if (!isEditingEnd) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isEditingEnd) activeColor else inactiveColor)
|
||||
.clickable { isEditingEnd = true }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${translation["auto_open_snaps.auto_open_schedule.end"] ?: "End"}: ${String.format("%02d:%02d", endState.hour, endState.minute)}",
|
||||
color = if (isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
|
||||
fontWeight = if (isEditingEnd) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TimePicker(
|
||||
state = if (isEditingEnd) endState else startState,
|
||||
colors = TimePickerDefaults.colors(
|
||||
clockDialColor = Color.White.copy(alpha = 0.05f),
|
||||
clockDialSelectedContentColor = Color.White,
|
||||
clockDialUnselectedContentColor = Color.White.copy(alpha = 0.7f),
|
||||
selectorColor = PurrfectPalette.glowPrimary,
|
||||
periodSelectorBorderColor = PurrfectPalette.glowPrimary,
|
||||
periodSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
periodSelectorUnselectedContainerColor = Color.Transparent,
|
||||
periodSelectorSelectedContentColor = Color.White,
|
||||
periodSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f),
|
||||
timeSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
timeSelectorUnselectedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
timeSelectorSelectedContentColor = Color.White,
|
||||
timeSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(text = translation["button.negative"], color = Color.White)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val startStr = String.format("%02d:%02d", startState.hour, startState.minute)
|
||||
val endStr = String.format("%02d:%02d", endState.hour, endState.minute)
|
||||
property.value.setAny("$startStr-$endStr")
|
||||
onDismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.positive"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -275,9 +431,10 @@ class AlertDialogs(
|
||||
DefaultDialogCard {
|
||||
var fieldValue by remember {
|
||||
mutableStateOf(property.value.get().toString().let {
|
||||
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
|
||||
TextFieldValue(
|
||||
text = it,
|
||||
selection = TextRange(it.length)
|
||||
text = t,
|
||||
selection = TextRange(t.length)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -291,10 +448,21 @@ class AlertDialogs(
|
||||
}
|
||||
.focusRequester(focusRequester),
|
||||
value = fieldValue,
|
||||
onValueChange = { fieldValue = it },
|
||||
keyboardOptions = when (property.key.dataType.type) {
|
||||
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
onValueChange = { newVal ->
|
||||
fieldValue = if (property.key.params.digitsOnlyInput) {
|
||||
val filtered = newVal.text.filter { ch -> ch.isDigit() }
|
||||
if (newVal.text != filtered) {
|
||||
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
newVal.copy(text = filtered)
|
||||
} else {
|
||||
newVal
|
||||
}
|
||||
},
|
||||
keyboardOptions = when {
|
||||
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
|
||||
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -360,6 +528,68 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
|
||||
val context = LocalContext.current
|
||||
val zoneId = remember { ZoneId.systemDefault() }
|
||||
val initialSelectedDateMillis = remember(property.value.get()) {
|
||||
runCatching {
|
||||
LocalDate
|
||||
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
.atStartOfDay(zoneId)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
|
||||
|
||||
DefaultDialogCard {
|
||||
DatePicker(
|
||||
state = datePickerState,
|
||||
showModeToggle = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
|
||||
) {
|
||||
Button(
|
||||
onClick = { dismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val selectedDate = datePickerState.selectedDateMillis?.let {
|
||||
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
|
||||
}
|
||||
|
||||
if (selectedDate == null) {
|
||||
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
|
||||
dismiss()
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = translation["button.ok"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
@@ -497,7 +727,7 @@ class AlertDialogs(
|
||||
currentColor = null
|
||||
}
|
||||
},
|
||||
label = { Text(text = "Hex Color") },
|
||||
label = { Text(text = translation["dialogs.hex_color_label"]) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
@@ -592,6 +822,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 = {}
|
||||
) {
|
||||
@@ -602,6 +834,13 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
fun dismissKeyboard() {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
mapView.value = remember {
|
||||
Configuration.getInstance().apply {
|
||||
@@ -611,7 +850,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)
|
||||
@@ -625,12 +877,37 @@ class AlertDialogs(
|
||||
|
||||
overlays.add(object: Overlay() {
|
||||
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
|
||||
mapView.invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
addMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(marker.value)
|
||||
}
|
||||
}
|
||||
@@ -685,29 +962,70 @@ class AlertDialogs(
|
||||
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
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
|
||||
}
|
||||
BackHandler {
|
||||
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
|
||||
if (shouldDismissKeyboard) {
|
||||
dismissKeyboard()
|
||||
locationName = ""
|
||||
addressResults = emptyList()
|
||||
searchJob?.cancel()
|
||||
searchJob = null
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
suspend fun search() {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -771,7 +1089,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
|
||||
)
|
||||
@@ -825,11 +1143,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
|
||||
)
|
||||
},
|
||||
@@ -901,6 +1219,7 @@ class AlertDialogs(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
|
||||
mapView.value?.controller?.setCenter(marker.value?.position)
|
||||
mapView.value?.invalidate()
|
||||
@@ -949,7 +1268,7 @@ class AlertDialogs(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Searching...",
|
||||
text = betterLocationTranslation["searching_label"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
@@ -1048,7 +1367,7 @@ class AlertDialogs(
|
||||
val lat = remember { mutableStateOf(coordinates.first.toString()) }
|
||||
val lon = remember { mutableStateOf(coordinates.second.toString()) }
|
||||
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = {
|
||||
customCoordinatesDialog = false
|
||||
},
|
||||
@@ -1097,7 +1416,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),
|
||||
@@ -1117,7 +1436,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),
|
||||
@@ -1161,23 +1480,7 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
|
||||
val currentValue = property.value.getNullable()?.toString() ?: "[]"
|
||||
val propertyName = translation[property.key.propertyName()]
|
||||
|
||||
MessageListManagerDialog(
|
||||
title = propertyName,
|
||||
messageListJson = currentValue,
|
||||
onSave = { newValue ->
|
||||
property.value.setAny(newValue)
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageListManagerDialog(
|
||||
@@ -1214,7 +1517,6 @@ class AlertDialogs(
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Message list
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1230,7 +1532,7 @@ class AlertDialogs(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = translation["auto_reply_messages.dialog.no_messages"],
|
||||
text = translation["bulk_messaging_action.no_messages_found"] ?: "No messages",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -1272,24 +1574,14 @@ class AlertDialogs(
|
||||
showAddDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["common.edit"] ?: "Edit", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
messageList = messageList.toMutableList().apply {
|
||||
removeAt(index)
|
||||
}
|
||||
messageList = messageList.toMutableList().apply { removeAt(index) }
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Icon(Icons.Default.Delete, contentDescription = translation["common.delete"] ?: "Delete", tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1299,7 +1591,6 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
// Add button
|
||||
Button(
|
||||
onClick = {
|
||||
editingIndex = -1
|
||||
@@ -1309,20 +1600,13 @@ class AlertDialogs(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["auto_reply_messages.dialog.add_message"])
|
||||
Text(text = translation["common.add"] ?: "Add Message")
|
||||
}
|
||||
|
||||
// Dialog buttons
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1331,17 +1615,14 @@ class AlertDialogs(
|
||||
) {
|
||||
Button(
|
||||
onClick = { onDismiss() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val gson = com.google.gson.Gson()
|
||||
val jsonString = gson.toJson(messageList)
|
||||
onSave(jsonString)
|
||||
onSave(gson.toJson(messageList))
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
@@ -1351,9 +1632,8 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
|
||||
// Add/Edit message dialog
|
||||
if (showAddDialog) {
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
@@ -1361,7 +1641,7 @@ class AlertDialogs(
|
||||
) {
|
||||
DefaultDialogCard {
|
||||
Text(
|
||||
text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["auto_reply_messages.dialog.edit_message"],
|
||||
text = if (editingIndex == -1) translation["common.add"] ?: "Add Message" else translation["common.edit"] ?: "Edit Message",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
@@ -1373,13 +1653,13 @@ class AlertDialogs(
|
||||
TextField(
|
||||
value = editingText,
|
||||
onValueChange = { editingText = it },
|
||||
label = { Text(translation["auto_reply_messages.dialog.message_label"]) },
|
||||
label = { Text(translation["common.message"] ?: "Message") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
placeholder = { Text(translation["auto_reply_messages.dialog.message_placeholder"]) }
|
||||
placeholder = { Text(translation["common.type_message"] ?: "Type message...") }
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -1390,32 +1670,22 @@ class AlertDialogs(
|
||||
) {
|
||||
Button(
|
||||
onClick = { showAddDialog = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Text(text = translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (editingText.isNotBlank()) {
|
||||
if (editingIndex == -1) {
|
||||
// Add new message
|
||||
messageList = messageList.toMutableList().apply {
|
||||
add(editingText)
|
||||
}
|
||||
} else {
|
||||
// Edit existing message
|
||||
messageList = messageList.toMutableList().apply {
|
||||
set(editingIndex, editingText)
|
||||
}
|
||||
messageList = messageList.toMutableList().apply {
|
||||
if (editingIndex == -1) add(editingText) else set(editingIndex, editingText)
|
||||
}
|
||||
}
|
||||
showAddDialog = false
|
||||
},
|
||||
enabled = editingText.isNotBlank()
|
||||
) {
|
||||
Text(text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["button.save"])
|
||||
Text(text = if (editingIndex == -1) translation["common.add"] ?: "Add" else translation["button.save"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,31 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Outline
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.*
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
@@ -19,20 +38,31 @@ import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.semantics.dialog
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext?.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
class DialogProperties constructor(
|
||||
val dismissOnBackPress: Boolean = true,
|
||||
val dismissOnClickOutside: Boolean = true,
|
||||
@@ -83,6 +113,17 @@ fun Dialog(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
var forceInline by remember(view) { mutableStateOf(false) }
|
||||
val hostActivity = remember(view) { view.context.findActivity() }
|
||||
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
|
||||
if (shouldUseInline) {
|
||||
InlineDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissOnClickOutside = properties.dismissOnClickOutside,
|
||||
content = content
|
||||
)
|
||||
return
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val composition = rememberCompositionContext()
|
||||
@@ -110,11 +151,30 @@ fun Dialog(
|
||||
}
|
||||
|
||||
DisposableEffect(dialog) {
|
||||
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
|
||||
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
|
||||
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
val showDialog = {
|
||||
try {
|
||||
dialog.prepareWindowForHost()
|
||||
if (!dialog.isShowing) {
|
||||
dialog.show()
|
||||
}
|
||||
} catch (_: WindowManager.BadTokenException) {
|
||||
forceInline = true
|
||||
}
|
||||
}
|
||||
|
||||
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
|
||||
showDialog()
|
||||
} else {
|
||||
val listener = object : OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
v.removeOnAttachStateChangeListener(this)
|
||||
showDialog()
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) = Unit
|
||||
}
|
||||
view.addOnAttachStateChangeListener(listener)
|
||||
}
|
||||
dialog.show()
|
||||
|
||||
onDispose {
|
||||
dialog.dismiss()
|
||||
@@ -131,6 +191,92 @@ fun Dialog(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissOnClickOutside: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val displayMetrics = LocalContext.current.resources.displayMetrics
|
||||
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
|
||||
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val fallbackBackDispatcherOwner = remember(onDismissRequest) {
|
||||
object : OnBackPressedDispatcherOwner {
|
||||
private val lifecycleRegistry = LifecycleRegistry(this).apply {
|
||||
currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
private val dispatcher = OnBackPressedDispatcher(onDismissRequest)
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = lifecycleRegistry
|
||||
|
||||
override val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||
get() = dispatcher
|
||||
}
|
||||
}
|
||||
val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
visible = true
|
||||
}
|
||||
|
||||
Popup(
|
||||
alignment = androidx.compose.ui.Alignment.Center,
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = dismissOnClickOutside
|
||||
),
|
||||
onDismissRequest = onDismissRequest
|
||||
) {
|
||||
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(screenWidthDp)
|
||||
.height(screenHeightDp)
|
||||
.then(
|
||||
if (dismissOnClickOutside) {
|
||||
Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onDismissRequest
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.semantics { dialog() },
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
|
||||
initialScale = 0.92f,
|
||||
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
|
||||
targetScale = 0.96f,
|
||||
animationSpec = tween(120)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogWindowProvider {
|
||||
val window: Window
|
||||
}
|
||||
@@ -279,6 +425,26 @@ private class DialogWrapper(
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareWindowForHost() {
|
||||
val hostActivity = composeView.context.findActivity()
|
||||
val hostToken = composeView.applicationWindowToken
|
||||
?: composeView.windowToken
|
||||
?: composeView.rootView?.applicationWindowToken
|
||||
?: composeView.rootView?.windowToken
|
||||
if (hostActivity == null) {
|
||||
when {
|
||||
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
|
||||
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
|
||||
}
|
||||
}
|
||||
if (hostToken != null) {
|
||||
window?.attributes = window?.attributes?.apply {
|
||||
token = hostToken
|
||||
}
|
||||
}
|
||||
hostActivity?.let { setOwnerActivity(it) }
|
||||
}
|
||||
|
||||
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
|
||||
dialogLayout.layoutDirection = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
||||
@@ -300,22 +466,26 @@ private class DialogWrapper(
|
||||
this.onDismissRequest = onDismissRequest
|
||||
this.properties = properties
|
||||
setLayoutDirection(layoutDirection)
|
||||
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
|
||||
val dialogWindow = window
|
||||
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
|
||||
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
|
||||
} == true
|
||||
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
|
||||
// Undo fixed size in internalOnLayout, which would suppress size changes when
|
||||
// usePlatformDefaultWidth is true.
|
||||
window?.setLayout(
|
||||
dialogWindow.setLayout(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
if (properties.decorFitsSystemWindows) {
|
||||
window?.setSoftInputMode(defaultSoftInputMode)
|
||||
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_kaladin.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
app/src/main/res/drawable/pfp_schrodingerspet.jpg
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
app/src/main/res/drawable/setup_slide_downloads.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/res/drawable/setup_slide_plus.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
app/src/main/res/drawable/setup_slide_upload_tag.jpg
Normal file
|
After Width: | Height: | Size: 354 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 30 KiB |