106 Commits

Author SHA1 Message Date
ΞTΞRNAL
dbbc52b67f v1.5.3 2026-03-25 21:59:31 +05:30
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
ΞTΞRNAL
901e4f81b8 fix: Missing changes 2026-03-22 20:32:16 +05:30
ΞTΞRNAL
a4e86b5ab4 fix(PR): Aphelion task page layout optimization by Kaladin
Aphelion task page layout optimization
2026-03-22 20:31:25 +05:30
ΞTΞRNAL
bdc6d12739 fix: splitting issue for snaps sent through send override 2026-03-22 20:30:34 +05:30
DarkKnight2122
6c8d5297c8 Task page layout harmonization. 2026-03-22 19:43:20 +05:30
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
ΞTΞRNAL
565a8b8287 v1.4.9 2026-03-21 03:48:43 +05:30
ΞTΞRNAL
1c612ebc8e fix: force AMOLED theme for newer snap versions 2026-03-21 02:09:33 +05:30
ΞTΞRNAL
8db363a8f0 add missing changelog 2026-03-20 22:19:18 +05:30
ΞTΞRNAL
4fab5cc4ab fix(PR): Crash when using the theme button in android 11 & 12 by Kaladin
fix: Crash when using the theme button in android 11 & 12.
2026-03-20 22:18:12 +05:30
ΞTΞRNAL
a7a15702f3 v1.4.8 2026-03-20 22:16:37 +05:30
DarkKnight2122
04aaefc748 fix: optimize theme reveal fallbacks for Android 11 and 12 2026-03-20 19:22:55 +05:30
ΞTΞRNAL
58c4be44f2 v1.4.6 2026-03-20 05:05:25 +05:30
ΞTΞRNAL
3d83c3116c revert: accidental change 2026-03-20 03:00:24 +05:30
ΞTΞRNAL
2b00898355 v1.4.5 2026-03-20 02:58:58 +05:30
ΞTΞRNAL
975a9a101f fix: duplicate opera download & mark snaps as seen buttons 2026-03-20 01:40:12 +05:30
ΞTΞRNAL
793659c63b fix: friend feed menu button overlapping for group chats 2026-03-19 22:13:45 +05:30
ΞTΞRNAL
5bdc1d7f59 feat(ui): Implement theme reveal transition by Kaladin
feat(ui): Implement theme reveal transition using AGSL
2026-03-18 22:12:24 +05:30
DarkKnight2122
fc66453167 feat(ui): implement theme reveal transition using AGSL 2026-03-18 19:03:37 +05:30
ΞTΞRNAL
84a9d01d6a fix(PR): Multiple fixes by Kaladin
Aphelion theme fixes.
2026-03-18 16:56:06 +05:30
DarkKnight2122
a4b948bba3 UI: Aphelion Changelog and Setup Enhancements 2026-03-18 11:31:55 +05:30
DarkKnight2122
8c720992de UI Fixes for Aphelion and Optimizations 2026-03-18 11:31:55 +05:30
DarkKnight2122
d1fc97cee8 Aphelion Theme Harmonization across tasks page 2026-03-18 11:31:53 +05:30
ΞTΞRNAL
9df49f1bff Merge pull request #114 from RSR1337/RSR
fix: FFMpegProcessor copyright
2026-03-17 11:19:02 +05:30
RSR/
652062769e fix: FFMpegProcessor copyright 2026-03-15 03:14:59 +04:00
ΞTΞRNAL
f1eb833655 fix(PR): Multiple fixes and improvements by RSR
feat:  Video Recording Timer , Auto Skip . fix: Fake Snap Score
2026-03-15 00:28:56 +05:30
RSR/
a27753d8ae feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score 2026-03-14 20:05:59 +04:00
ΞTΞRNAL
da8a261202 feat(PR): Spoof SnapScore by RSR
RSR
2026-03-12 05:20:04 +05:30
ΞTΞRNAL
532ebfe0a7 Merge pull request #112 from particle-box/revert-110-revert-109-RSR
Revert "Revert "feat: Spoof Snap Score""
2026-03-12 05:19:12 +05:30
ΞTΞRNAL
bf7f371022 Revert "Revert "feat: Spoof Snap Score"" 2026-03-12 05:18:59 +05:30
RSR/
15c9a3fd5d Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-12 02:27:47 +04:00
RSR/
bb0ab20a5b fix 2026-03-12 02:27:33 +04:00
ΞTΞRNAL
9a507684ee Merge pull request #110 from particle-box/revert-109-RSR
Revert "feat: Spoof Snap Score"
2026-03-12 03:53:23 +05:30
ΞTΞRNAL
1fbf82b4fb Revert "feat: Spoof Snap Score" 2026-03-12 03:52:54 +05:30
ΞTΞRNAL
53696c26f4 feat(PR): Spoof SnapScore by RSR
feat: Spoof Snap Score
2026-03-12 03:47:24 +05:30
RSR/
381b190535 Merge branch 'particle-box:dev' into RSR 2026-03-12 02:12:51 +04:00
RSR/
50199b052f fix 2026-03-12 02:12:09 +04:00
particle-box
2135d66124 many fixes & improvements(v1.4.1) 2026-03-12 01:04:30 +05:30
RSR/
6948d86efc feat: Spoof Snap Score 2026-03-11 19:47:13 +04:00
ΞTΞRNAL
481e5840f0 fix overlay position by AhmedRaza 2026-03-11 11:57:12 +05:30
ΞTΞRNAL
216bb71fef feat(PR): Improved Device Spoof by RSR
Improved the spoof device feature
2026-03-11 04:57:16 +05:30
RSR/
181d19424f Improved the spoof device feature 2026-03-11 01:47:07 +04:00
ΞTΞRNAL
a4d2513138 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-10 21:02:59 +05:30
ΞTΞRNAL
9c0f14e797 fix some UI bugs 2026-03-10 21:02:25 +05:30
ΞTΞRNAL
800b40aaae add missing changelog 2026-03-10 20:40:36 +05:30
ΞTΞRNAL
9577dadd38 feat(PR): Aphelion Theme by Kaladin
Introducing Aphelion Theme
2026-03-10 19:36:31 +05:30
ΞTΞRNAL
d4a084f896 bump version to 1.4.0 2026-03-10 19:35:52 +05:30
ΞTΞRNAL
28df958197 Merge branch 'dev' into themes 2026-03-10 19:27:32 +05:30
Kaladin
9b0f336f91 Update RetroGameScreen.kt 2026-03-10 19:10:20 +05:30
DarkKnight2122
c52f60a29b Introducing Aphelion Theme 2026-03-10 18:49:31 +05:30
ΞTΞRNAL
a327786d9e feat(PR): Story Snap Batch Download by RSR
feat: Story Snap List Download
2026-03-10 18:46:54 +05:30
ΞTΞRNAL
2b0c6cae36 fix multiple issues 2026-03-10 18:43:57 +05:30
particle-box
05a383c823 fix: crash issues 2026-03-10 13:34:32 +05:30
RSR/
13cd3656fd Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-10 02:07:14 +04:00
RSR/
07a8d3041e feat: Download story snaps , Fix Translation 2026-03-10 02:00:20 +04:00
particle-box
5ece2ec408 fix: mark snaps as seen button for newer snap versions 2026-03-10 01:43:29 +05:30
ΞTΞRNAL
7f9b911f0b multiple fixes & improvements 2026-03-09 20:05:28 +05:30
DarkKnight2122
b1b6a4ae6f added high quality vectors 2026-03-09 02:16:53 +05:30
particle-box
eeee0c2e72 v1.3.9 2026-03-08 19:26:26 +05:30
particle-box
07d4a4f833 many fixes & improvements 2026-03-08 17:19:38 +05:30
particle-box
4adcff36e3 improve ads & metrics blocking 2026-03-08 04:47:58 +05:30
particle-box
26077a8c87 fix: translation 2026-03-07 04:41:46 +05:30
particle-box
28982aa655 v1.3.8 2026-03-07 04:33:21 +05:30
particle-box
89748cd261 multiple fixes & improvements 2026-03-07 00:00:36 +05:30
particle-box
97797127bd Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-06 01:49:04 +05:30
particle-box
23d8ff0370 fix: crash issue for some devices 2026-03-06 01:48:34 +05:30
ΞTΞRNAL
d986440f9f fix: add missing changelog 2026-03-04 15:19:44 +05:30
particle-box
fc3b1bcde1 prepare for v1.3.5 2026-03-04 15:18:25 +05:30
ΞTΞRNAL
0289f96019 Merge pull request #105 from particle-box/revert-102-dev
revert(PR): 102
2026-03-04 14:17:55 +05:30
ΞTΞRNAL
ec9ecac15c Revert "Feature: UI Modernization, Themed Icons, and Performance Optimization" 2026-03-04 14:15:32 +05:30
ΞTΞRNAL
5efccc9e78 many changes by DarkKnight2122
Feature: UI Modernization, Themed Icons, and Performance Optimization
2026-03-04 09:17:07 +05:30
ΞTΞRNAL
ae53cc1f72 many new features & fixes by RSR
- Story counter & Story Source
- fix: Media Quality Override
2026-03-04 09:13:04 +05:30
ΞTΞRNAL
5b170ebae6 Merge branch 'dev' into RSR 2026-03-04 09:11:37 +05:30
RSR/
87ca0eaa82 fix: Media Quality 2026-03-04 07:28:26 +04:00
RSR/
5496dc7eb5 feat: Story Counter , Story Source 2026-03-04 07:15:38 +04:00
particle-box
183164fd52 fix:snaps saving in chat & new look for msg edit history by AhmedRaza 2026-03-04 00:57:05 +05:30
particle-box
4987577001 fix: better location overlay position for newer snap versions 2026-03-04 00:17:32 +05:30
particle-box
c7a879fa62 fix: better location overlay crash 2026-03-03 23:55:50 +05:30
particle-box
d6e74cb207 fix: migration side effects 2026-03-03 23:37:30 +05:30
particle-box
1ccdaee879 kavaref migration 2026-03-03 21:28:18 +05:30
ΞTΞRNAL
956bdda6c0 migration from libxposed API to YukiHook 2026-03-03 19:16:14 +05:30
RSR/
1b1f68c6cf : ) 2026-02-26 06:44:29 +04:00
DarkKnight2122
b6f9c10b21 Implemented high-precision Vector Themed Icons and optimized system Updater logic, and improved the haptics manager wide. 2026-02-25 19:48:18 +05:30
RSR/
fb3f1f55c7 feat:StoryCounter
Display the number of snaps in the story
2026-02-25 05:08:14 +04:00
DarkKnight2122
f8e5d7370a Standardized sub-page layouts and enhanced system stability across all modules 2026-02-24 16:45:40 +05:30
DarkKnight2122
7623ec30c9 Optimized component animations with basicMarquee integration 2026-02-24 16:35:15 +05:30
DarkKnight2122
7be8e33cc6 Implemented morphing Infinity header 2026-02-24 16:35:15 +05:30
ΞTΞRNAL
bc252ea9a3 prepare for v1.3.2 2026-02-24 16:03:05 +05:30
particle-box
4aba9c815f multiple fixes 2026-02-24 12:51:25 +05:30
ΞTΞRNAL
05c71c7ef4 fix: checksum 2026-02-24 03:41:22 +05:30
ΞTΞRNAL
57ebadbc51 fix: checksum 2026-02-24 03:38:00 +05:30
particle-box
bdf3808526 fix: slow builds 2026-02-24 02:21:26 +05:30
particle-box
e8d1f62ff1 add translations back 2026-02-24 02:10:33 +05:30
particle-box
b64faf31ae Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-02-24 01:58:11 +05:30
particle-box
ed86916ed7 temporary removal 2026-02-24 01:57:47 +05:30
ΞTΞRNAL
5935afb6e1 fix(PR): Media file Picker and added mapper by RSR
fix MediaFilePicker on snap13.79 and added mapper
2026-02-18 11:43:03 +05:30
RSR/
1ac1542384 fix MediaFilePicker on snap13.79 and added mapper 2026-02-16 20:31:42 +04:00
ΞTΞRNAL
8eea50e745 fix: accidental change 2026-02-14 14:27:07 +05:30
ΞTΞRNAL
795bc1f96a address fdroid bot feedback
Added SHA256 checksum for Gradle distribution.
2026-02-14 14:26:06 +05:30
particle-box
0ab60d8b20 add icons and banner 2026-02-14 14:06:26 +05:30
particle-box
627abb3927 add support for frdroid 2026-02-14 14:04:17 +05:30
ΞTΞRNAL
f32a4c4a23 add buiding instructions 2026-02-14 13:39:53 +05:30
RSR/
e3008e0c9c fix 2026-02-07 19:20:39 +04:00
214 changed files with 15346 additions and 5369 deletions

View File

@@ -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

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ cloudflare/**/allowed_codes.local.*
security/allowed-codes.local.*
security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

259
README.md
View File

@@ -16,7 +16,7 @@
[![Downloads](https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/releases)
[![License](https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=1e1e2e)](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>
@@ -40,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 UIdesigned 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>
@@ -246,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 isnt.
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 Snapchats 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!**
@@ -425,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
@@ -441,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
- [OMVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation

View File

@@ -151,7 +151,7 @@ android {
applicationId = rootProject.ext["applicationId"].toString()
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
minSdk = 30
targetSdk = 36
multiDexEnabled = true
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
@@ -267,6 +267,7 @@ dependencies {
}
implementation(project(":core"))
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(project(":common"))
implementation(project(":native"))
implementation(libs.androidx.documentfile)

View File

@@ -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.** { *; }

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -4,6 +4,7 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -27,10 +28,25 @@ import kotlin.system.measureTimeMillis
class BridgeService : Service() {
private lateinit var remoteSideContext: RemoteSideContext
lateinit var syncCallback: SyncCallback
private var syncCallback: SyncCallback? = null
var messagingBridge: MessagingBridge? = null
@Volatile
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
private fun clearSyncCallback() {
syncCallback = null
}
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
pendingSocialSnapshotCallback = callback
}
fun clearEphemeralSocialSnapshotRequest() {
pendingSocialSnapshotCallback = null
}
override fun onDestroy() {
clearSyncCallback()
if (::remoteSideContext.isInitialized) {
remoteSideContext.bridgeService = null
}
@@ -47,36 +63,58 @@ class BridgeService : Service() {
}
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
val callback = syncCallback ?: return
runCatching {
if (!syncCallback.asBinder().pingBinder()) {
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
val database = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
callback.syncFriend(id)
}
SocialScope.GROUP -> {
if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
callback.syncGroup(id)
}
} ?: run {
if (updateOnly) {
when (scope) {
SocialScope.FRIEND -> database.deleteFriend(id)
SocialScope.GROUP -> database.deleteGroup(id)
}
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
when (scope) {
SocialScope.FRIEND -> {
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) } ?: run {
if (updateOnly) {
database.deleteFriend(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
SocialScope.GROUP -> {
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) } ?: run {
if (updateOnly) {
database.deleteGroup(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
}
}.onFailure {
if (it is RemoteException) {
clearSyncCallback()
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return@onFailure
}
remoteSideContext.log.error("Failed to sync $scope $id", it)
}
}
@@ -162,6 +200,7 @@ class BridgeService : Service() {
}
override fun sync(callback: SyncCallback) {
clearSyncCallback()
syncCallback = callback
measureTimeMillis {
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
@@ -185,10 +224,15 @@ class BridgeService : Service() {
friends: List<String>
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
)
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
return
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}
override fun getScopeNotes(id: String): String? {

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.download
import android.content.ContentUris
import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
@@ -11,11 +12,9 @@ import android.provider.MediaStore
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.Constants
@@ -61,6 +60,14 @@ class DownloadProcessor (
private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback
) {
companion object {
private val downloadSemaphore = Semaphore(3)
}
private data class GallerySaveResult(
val uri: Uri,
val alreadyDownloaded: Boolean = false
)
private val translation by lazy {
remoteSideContext.translation.getCategory("download_processor")
@@ -118,78 +125,36 @@ class DownloadProcessor (
}
}
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
val fileName = buildOutputFileName(metadata.outputPath, fileType)
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
if (configuredFolder.isBlank()) {
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
?: throw Exception("Failed to save media (no output uri)")
pendingTask.task.extra = outputUri.toString()
pendingTask.success()
callbackOnSuccess(fileName)
return
}
val saveResult = if (configuredFolder.isBlank()) {
saveToSystemDefault(fileName, fileType, inputFile, metadata)
} else {
runCatching {
saveToConfiguredFolder(
configuredFolder = configuredFolder,
fileName = fileName,
fileType = fileType,
inputFile = inputFile,
metadata = metadata,
pendingTask = pendingTask
)
}.onFailure {
remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it)
}.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)
} ?: throw Exception("Failed to save media (no output uri)")
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
pendingTask.task.extra = saveResult.uri.toString()
pendingTask.success()
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!!
}
} else {
outputFolder
}
}
// checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file.
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
return@let
}
remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputFile.inputStream().use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = existingInputStream.read(buffer2)
if (read1 != read2 || !buffer1.contentEquals(buffer2)) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
}
}
pendingTask.task.extra = existingFile.uri.toString()
pendingTask.success()
if (saveResult.alreadyDownloaded) {
callbackOnFailure(translation["already_downloaded_toast"])
return
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
pendingTask.updateProgress("Saving media to gallery")
remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
pendingTask.task.extra = outputFile.uri.toString()
pendingTask.success()
runCatching {
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = outputFile.uri
data = saveResult.uri
})
}.onFailure {
remoteSideContext.log.error("Failed to scan media file", it)
@@ -205,15 +170,184 @@ class DownloadProcessor (
}
}
private fun saveToConfiguredFolder(
configuredFolder: String,
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
pendingTask: PendingTask,
): GallerySaveResult {
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name)
?: folder.createDirectory(name)
?: throw Exception("Failed to create output directory $name")
}
} else {
outputFolder
}
}
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
} else {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
?: throw Exception("Failed to open existing media for comparison")
existingInputStream.use { currentExistingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputFile.inputStream().use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
}
}
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
?: throw Exception("Failed to create output file $fileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $fileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
for (index in 0 until length) {
if (left[index] != right[index]) return false
}
return true
}
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.substringAfterLast("/").ifBlank { "media" }
val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName))
val currentExtension = rawName.substringAfterLast(".", "").lowercase()
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val extension = if (currentExtension == targetExtension) {
currentExtension
} else {
targetExtension
}
return "$sanitizedBase.$extension"
}
private fun sanitizeFileName(name: String): String {
return name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ")
.trim()
.trim('.')
.ifBlank { "media" }
}
private fun sanitizeRelativePath(path: String): String {
return path.split("/")
.mapNotNull { segment ->
segment.trim()
.takeIf { it.isNotBlank() }
?.let(::sanitizeFileName)
}
.joinToString("/")
}
private fun appendNameSuffix(fileName: String, index: Int): String {
val extension = fileName.substringAfterLast('.', "")
val baseName = fileName.substringBeforeLast(".", fileName)
return if (extension.isBlank()) {
"$baseName ($index)"
} else {
"$baseName ($index).$extension"
}
}
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?"
val selectionArgs = arrayOf(fileName, relativePath)
return remoteSideContext.androidContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
ContentUris.withAppendedId(collection, id)
}
}
private fun contentMatches(uri: Uri, inputFile: File): Boolean {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false
return streamsMatch(existingInputStream, inputFile.inputStream())
}
private fun filesMatch(existingFile: File, inputFile: File): Boolean {
return streamsMatch(existingFile.inputStream(), inputFile.inputStream())
}
private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean {
existingInputStream.use { currentExistingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputInputStream.use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
return false
}
if (read1 == -1) break
}
}
}
return true
}
private fun saveToSystemDefault(
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
): Uri? {
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
.trim('/')
): GallerySaveResult? {
val subPath = sanitizeRelativePath(
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
.trim('/')
)
val baseRelative = when {
fileType.isImage -> Environment.DIRECTORY_PICTURES
fileType.isVideo -> Environment.DIRECTORY_MOVIES
@@ -228,23 +362,69 @@ class DownloadProcessor (
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
}
val resolver = remoteSideContext.androidContext.contentResolver
val uri = resolver.insert(collection, values) ?: return null
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: return null
uri
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName")
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
}
for (attempt in 0..100) {
val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt)
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = runCatching {
resolver.insert(collection, values)
}.onFailure {
remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}")
}.getOrNull() ?: continue
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream for $candidateName")
ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}.also { resolver.update(uri, it, null, null) }
return GallerySaveResult(uri)
}.onFailure {
runCatching { resolver.delete(uri, null, null) }
remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it)
}
}
throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath")
} else {
@Suppress("DEPRECATION")
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
destDir.mkdirs()
val destFile = File(destDir, fileName)
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
var destFile = File(destDir, sanitizedFileName)
if (destFile.exists()) {
if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
var suffix = 1
while (destFile.exists()) {
destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++))
}
}
FileOutputStream(destFile).use { out ->
inputFile.inputStream().use { it.copyTo(out) }
}
@@ -255,7 +435,7 @@ class DownloadProcessor (
}
)
}
Uri.fromFile(destFile)
GallerySaveResult(Uri.fromFile(destFile))
}
}
@@ -263,8 +443,7 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf<Job>()
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
val downloadedMedias = mutableMapOf<InputMedia, File>()
var totalSize = 1L
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
@@ -277,71 +456,72 @@ class DownloadProcessor (
)
}
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
coroutineScope {
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}
}.also { jobs.add(it) }
}
}
jobs.joinAll()
downloadedMedias
return downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
@@ -352,24 +532,24 @@ class DownloadProcessor (
val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.CONVERSION,
inputs = listOf(media.file.absolutePath),
inputs = listOf(media.absolutePath),
output = outputFile
))
media.file.delete()
media.delete()
saveMediaToGallery(pendingTask, outputFile, metadata)
outputFile.delete()
return
}
}
saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
saveMediaToGallery(pendingTask, media, metadata)
media.delete()
return
}
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
@@ -379,7 +559,7 @@ class DownloadProcessor (
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
dashPlaylistFile.outputStream().use {
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
}
@@ -404,7 +584,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -424,7 +604,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 +619,115 @@ 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 replace the downloaded media with the extracted ones
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = it
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = DownloadedFile(it, FileType.fromFile(it))
}
}
oldDownloadedMedias.forEach { (_, value) ->
value.delete()
}
shouldMergeOverlay = true
}
oldDownloadedMedias.forEach { (_, value) ->
value.file.delete()
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
shouldMergeOverlay = true
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
}

View File

@@ -106,7 +106,18 @@ class FFMpegProcessor(
if (session.returnCode.isValueSuccess) {
Result.success(session)
} else {
Result.failure(Exception(session.output))
val output = session.output
val errorMsg = when {
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
else -> {
val lines = output.lines().filter { line ->
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
}
lines.lastOrNull()?.take(400)
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
}
}
Result.failure(Exception(errorMsg))
}
)
}, logFunction@{ log ->
@@ -119,11 +130,6 @@ class FFMpegProcessor(
}, { 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")
}
suspend fun execute(args: Request) {
// load ffmpeg native sync to avoid native crash
synchronized(this) { FFmpegKit.listSessions() }
@@ -140,7 +146,7 @@ class FFMpegProcessor(
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
@@ -215,7 +221,7 @@ class FFMpegProcessor(
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
@@ -232,7 +238,6 @@ class FFMpegProcessor(
}
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
outputArguments += "-c:a" to "pcm_s16le"
}
Action.MERGE_AUDIO_STREAMS -> {
inputArguments.clear()
@@ -241,40 +246,21 @@ class FFMpegProcessor(
args.inputs.forEachIndexed { index, input ->
inputArguments += "-i" to input
val offset = args.inputDelayOffsets?.get(input) ?: 0L
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
if (offset > 0) {
filterParts.append(",adelay=$offset|$offset[a$index];")
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
} else {
filterParts.append(",acopy[a$index];")
filterParts.append("[$index:a]acopy[a$index];")
}
}
args.inputs.indices.forEach { index ->
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-c:a" to "libmp3lame"
outputArguments += "-b:a" to "192k"
outputArguments += "-ar" to "48000"
outputArguments += "-ac" to "2"
}
}
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)
}
}

View File

@@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
}
}
fun AppDatabase.replaceMessagingData(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
executeAsync {
database.beginTransaction()
try {
val friendIds = friends.map { it.userId }.toSet()
val groupIds = groups.map { it.conversationId }.toSet()
getFriends().forEach { friend ->
if (friend.userId !in friendIds) {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
}
}
getGroups().forEach { group ->
if (group.conversationId !in groupIds) {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
}
}
friends.forEach { friend ->
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
)
)
friend.streaks?.takeIf { it.length > 0 }?.also {
val streaks = getFriendStreaks(friend.userId)
database.execSQL(
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
streaks?.notify != false,
it.expirationTimestamp,
it.length
)
)
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
}
groups.forEach { group ->
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
}
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery(
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)

View File

@@ -127,15 +127,26 @@ class TaskManager(
fun getTaskByHash(hash: String?): Task? {
if (hash == null) return null
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
return readTaskFromCursor(cursor)
return runBlocking {
suspendCoroutine { continuation ->
queueExecutor.execute {
runCatching {
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
if (cursor.moveToNext()) {
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
} else {
continuation.resumeWith(Result.success(null))
}
}
}.onFailure {
continuation.resumeWith(Result.failure(it))
}
}
}
}
return null
}
fun getActiveTasks() = activeTasks
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
val tasks = mutableMapOf<Long, Task>()

View 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
@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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>>()
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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!!)

View File

@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)

View File

@@ -1,343 +1,126 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.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.PurrfectMarqueeText
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,
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.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),
modifier = Modifier.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
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(
PurrfectMarqueeText(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
)
}
}
}
}
}

View File

@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0)
internal val logListState = LazyListState()
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
internal val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
private fun clearLogsAndReload() {
internal fun clearLogsAndReload() {
context.coroutineScope.launch {
context.log.clearLogs()
withContext(Dispatchers.Main) {
@@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() {
}
}
private fun exportLogs() {
internal fun exportLogs() {
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"])
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
visibleLogs.add(line)
}
}
}
}
readerResult.onFailure {
context.longToast(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 +167,19 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogsFloatingBar(
internal fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
var showDropDown by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var showMenu by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding),
shape = RoundedCornerShape(28.dp),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
@@ -292,87 +187,93 @@ class HomeLogs : Routes.Route() {
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["common.back"],
tint = Color.White
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectPalette.glowSecondary
Text(
text = translation["manager.routes.home_logs"] ?: "Logs",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = translation["refresh_button_description"],
tint = Color.White
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = Color.White
)
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
onExport()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
},
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
onClick = {
onExport()
showMenu = false
}
)
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = Color(0xFFFF9CAB)
)
)
}
},
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
onClick = {
onClear()
showMenu = false
}
)
}
}
}
@@ -381,7 +282,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun EmptyLogsState() {
internal fun EmptyLogsState() {
Column(
modifier = Modifier
.fillMaxSize()
@@ -422,8 +323,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
// Normalize overly fragmented log text (some entries were rendered with one character per line)
internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
val normalizedMessage = remember(line.message) {
val cleaned = line.message.replace("\r", "")
val fragments = cleaned.lines()
@@ -534,14 +434,14 @@ class HomeLogs : Routes.Route() {
}
}
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
LogLevel.WARN -> Color(0xFFFFD782)
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
}
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
LogLevel.DEBUG -> "Debug"
LogLevel.INFO -> "Info"
LogLevel.VERBOSE -> "Verbose"
@@ -550,14 +450,14 @@ class HomeLogs : Routes.Route() {
LogLevel.ASSERT -> "Assert"
}
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
}
private fun shouldHideLog(line: LogLine): Boolean {
internal fun shouldHideLog(line: LogLine): Boolean {
val message = line.message.lowercase()
val tag = line.tag.lowercase()
return message.startsWith("blocked ep") ||

View File

@@ -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,7 @@ 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.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -137,7 +142,7 @@ class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
companion object {
private const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
val cardMargin = 10.dp
val pageBackgroundGradient = Brush.verticalGradient(
listOf(
@@ -148,23 +153,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"]
@@ -176,7 +181,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 {
@@ -191,7 +196,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) {
@@ -214,13 +219,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
}
@@ -244,7 +253,7 @@ class HomeRootSection : Routes.Route() {
}
@Composable
private fun HeroBadge(text: String) {
internal fun HeroBadge(text: String) {
Text(
text = text,
color = Color.White,
@@ -624,7 +633,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))
@@ -686,7 +695,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
}
@@ -704,13 +713,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()
@@ -732,618 +741,23 @@ class HomeRootSection : Routes.Route() {
}
@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 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 hasInitializedQuickTiles = prefs.getBoolean(QUICK_TILES_INITIALIZED_PREF, false)
when {
storedTiles.isNotEmpty() -> {
if (!hasInitializedQuickTiles) {
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
}
storedTiles
}
hasInitializedQuickTiles -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
allQuickTileNames
}
}
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"]
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
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,
onWebsiteClick = {
context.androidContext.openLink(
"https://purrfectsnap.vercel.app/",
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 = translation["changelog_dialog_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["changelog_dialog_update_button"],
onConfirm = {
showChangelogDialog = false
handleUpdateAction()
},
dismissButtonText = translation["changelog_dialog_cancel_button"],
onDismiss = { showChangelogDialog = false },
confirmEnabled = !changelogLoading,
showCloseButton = false,
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 = translation["changelog_dialog_loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
changelogError != null -> {
Text(
text = changelogError ?: translation["changelog_dialog_error"],
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = changelogText ?: translation["changelog_dialog_empty"],
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
}
}
)
}
if (showAnnouncementsDialog) {
AestheticDialog(
onDismissRequest = { showAnnouncementsDialog = false },
title = translation["announcements_dialog_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["announcements_dialog_close_button"],
onConfirm = { showAnnouncementsDialog = false },
confirmEnabled = !announcementsLoading,
showCloseButton = false,
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 = translation["announcements_dialog_loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
announcementsError != null -> {
Text(
text = announcementsError ?: translation["announcements_dialog_error"],
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = announcementsText ?: translation["announcements_dialog_empty"],
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)
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
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>()
@@ -1362,3 +776,7 @@ private fun extractChangelogForVersion(raw: String, version: String): String {
}
}

View File

@@ -34,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
@@ -45,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
@@ -53,6 +53,7 @@ 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
@@ -68,10 +69,10 @@ 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
@@ -128,11 +129,31 @@ class HomeSettings : Routes.Route() {
workManager.cancelUniqueWork("purrfectsnap_update_check")
}
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeSettings.SettingsScreen(nav)
}
}
}
@Composable
private fun RowTitle(title: String) {
internal fun RowTitle(title: String) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
@@ -141,8 +162,9 @@ class HomeSettings : Routes.Route() {
color = Color.White
)
}
@Composable
private fun PremiumPreferenceToggle(
internal fun PremiumPreferenceToggle(
sharedPreferences: SharedPreferences,
key: String,
text: String,
@@ -214,7 +236,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
val hapticFeedback = LocalHapticFeedback.current
@@ -246,7 +268,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
var confirmationDialog by remember {
mutableStateOf(false)
}
@@ -294,8 +316,9 @@ class HomeSettings : Routes.Route() {
}
}
}
@Composable
private fun ShiftedRow(
internal fun ShiftedRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
@@ -308,660 +331,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 positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
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)
var showResetSetupDialog by remember { mutableStateOf(false) }
@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)
) {
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = 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)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
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 = translation["reset_setup_title"])
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
showResetSetupDialog = true
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["reset_setup_action"],
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = translation["reset_setup_action"],
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 = translation["import_button"])
}
}
}
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
onClick = { routes.loggerHistory.navigate() },
colors = sharedOutlinedColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Text(translation["view_logger_history_button"])
}
if (showImportDialog) {
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = importLabel,
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
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 ?: "")
)
)
}
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
}
}
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,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
}
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)
}
}
}

View File

@@ -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
@@ -196,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()
@@ -207,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
.padding(bottom = bottomPadding),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
FloatingTopBar(
title = translation["title"],
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

View File

@@ -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"])
}
}
}
}
}
}
}

View File

@@ -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
@@ -221,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)
}
}
@@ -256,7 +260,12 @@ class BetterLocationRoot : Routes.Route() {
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 {
@@ -279,14 +288,18 @@ class BetterLocationRoot : Routes.Route() {
)
}
var currentProvider by remember { mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.get()) }
var currentApiKey by remember { mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.get()) }
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.get()
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
}) {
alertDialogs.UniqueSelectionDialog(providerProperty)
}
@@ -295,12 +308,12 @@ class BetterLocationRoot : Routes.Route() {
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.get()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}) {
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
showApiKeyDialog = false
context.config.writeConfig()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.get()
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
}
}
}
@@ -375,8 +388,8 @@ class BetterLocationRoot : Routes.Route() {
property = coordinatesProperty,
marker = marker,
mapView = mapView,
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.get(),
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.get(),
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
saveCoordinates = {
addSavedCoordinateDialog = true
}
@@ -665,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"])
}
}
}

View File

@@ -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("") }
@@ -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,7 +432,7 @@ class ScriptingRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {}
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
internal fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"]
Box(
modifier = Modifier
@@ -483,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
@@ -500,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(
@@ -800,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun CatalogTabContent(
internal fun CatalogTabContent(
scriptingFolder: DocumentFile?
) {
val coroutineScope = rememberCoroutineScope()
@@ -833,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingHeader(
internal fun ScriptingHeader(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit,
@@ -911,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingTabSwitcher(
internal fun ScriptingTabSwitcher(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit
@@ -958,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -219,19 +219,26 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
@@ -248,6 +255,13 @@ class AddFriendDialog(
}
}
}
DisposableEffect(Unit) {
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
me.eternal.purrfectsnap.ui.util.Dialog(
onDismissRequest = {

View File

@@ -35,31 +35,45 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
class SocialRootSection : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
private fun updateScopeLists() {
internal fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
}
}
internal fun requestLatestSnapshot() {
runCatching {
context.androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
context.log.error("Failed to request latest social snapshot", it)
}
}
@Composable
private fun ScopeList(
internal fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
@@ -76,7 +90,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) {
@@ -185,137 +198,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"]
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 +377,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialHeader(
internal fun SocialHeader(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit,
@@ -552,7 +451,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialTabSwitcher(
internal fun SocialTabSwitcher(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit
@@ -598,7 +497,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun EmptyState(scope: SocialScope) {
internal fun EmptyState(scope: SocialScope) {
val title = when (scope) {
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
@@ -625,13 +524,13 @@ class SocialRootSection : Routes.Route() {
modifier = Modifier.size(26.dp)
)
Text(
text = title,
text = title ?: "",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"],
text = translation["social_empty_hint"] ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -640,7 +539,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun StatPill(label: String, value: Int) {
internal fun StatPill(label: String, value: Int) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.08f),
@@ -669,3 +568,7 @@ class SocialRootSection : Routes.Route() {
}
}
}

View File

@@ -0,0 +1,237 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.scaleOnPress
@Composable
fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
val horizontalPadding = 24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding + 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(controlsHeight))
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Box(modifier = Modifier.fillMaxWidth().background(PurrfectPalette.panelGradient)) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = translation["about_title"] ?: "About",
fontSize = 32.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
fontFamily = avenirNext,
modifier = Modifier.clickable(
interactionSource = tapSource,
indication = null,
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
}
)
)
Text(
text = translation["about_tagline"] ?: "",
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = translation["about_lead_developers_title"] ?: "Lead Developers",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
}
}
}
// LITERAL OUR STORY VERBATIM RESTORATION (Justified & Indented)
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
PurrfectMarqueeText(
text = translation["about_story_title"] ?: "About Us",
color = Color.White,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(0.7f).padding(vertical = 4.dp),
thickness = 1.2.dp,
color = Color.White.copy(alpha = 0.3f)
)
Text(
text = aboutStory,
fontSize = 15.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Justify,
style = LocalTextStyle.current.copy(
lineHeight = 22.sp,
textIndent = TextIndent(firstLine = 24.sp)
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
)
}
}
Surface(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = translation["about_thanks_title"] ?: "Special Thanks", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
}

View File

@@ -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"
)
}
}
}

View File

@@ -0,0 +1,824 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Widgets
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import okhttp3.OkHttpClient
import okhttp3.Request
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
@Composable
fun LivingPurrAura(isActive: Boolean, haptic: HapticFeedback) {
val infiniteTransition = rememberInfiniteTransition(label = "aura")
val pulseScale by infiniteTransition.animateFloat(
initialValue = 0.88f, targetValue = 1.12f,
animationSpec = infiniteRepeatable(tween(1600, easing = EaseInOutSine), RepeatMode.Reverse),
label = "pulse"
)
val glow1 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, easing = LinearEasing), RepeatMode.Restart),
label = "g1"
)
val glow2 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 1100, easing = LinearEasing), RepeatMode.Restart),
label = "g2"
)
val glow3 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, delayMillis = 2200, easing = LinearEasing), RepeatMode.Restart),
label = "g3"
)
val coreColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3),
animationSpec = tween(800), label = "coreColor"
)
val secondaryColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowSecondary else Color(0xFF6B6B7A),
animationSpec = tween(800), label = "secondaryColor"
)
Canvas(modifier = Modifier.size(44.dp)) {
val center = Offset(size.width / 2, size.height / 2)
val baseRadius = 6.dp.toPx()
fun drawAuroraGlow(progress: Float, alphaMultiplier: Float) {
if (!isActive || progress <= 0f) return
val auroraRadius = baseRadius * (1.2f + 4.5f * progress)
drawCircle(
brush = Brush.radialGradient(
0.0f to coreColor.copy(alpha = 0.25f * (1f - progress) * alphaMultiplier),
0.6f to secondaryColor.copy(alpha = 0.12f * (1f - progress) * alphaMultiplier),
1.0f to Color.Transparent,
center = center, radius = auroraRadius
),
radius = auroraRadius, center = center
)
}
drawAuroraGlow(glow1, 0.8f)
drawAuroraGlow(glow2, 0.5f)
drawAuroraGlow(glow3, 0.3f)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(coreColor, secondaryColor),
center = center, radius = baseRadius * pulseScale
),
radius = baseRadius * pulseScale, center = center
)
drawCircle(
color = Color.White.copy(alpha = 0.5f),
radius = (baseRadius * pulseScale) * 0.25f,
center = Offset(center.x - (baseRadius * pulseScale) * 0.3f, center.y - (baseRadius * pulseScale) * 0.3f)
)
}
}
@Composable
fun AphelionTopBarActionChip(
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
haptic: HapticFeedback,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(20.dp).graphicsLayer {
val s = 0.82f + (0.18f * shrinkFactor); scaleX = s; scaleY = s
}
)
if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Clip,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.widthIn(max = (75 * shrinkFactor).dp)
)
}
}
}
}
@Composable
fun RowScope.AphelionHomeActionChips(
scrollState: androidx.compose.foundation.ScrollState,
haptic: HapticFeedback
) {
val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
AphelionTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() }
AphelionTopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() }
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AphelionHeroSection(
versionName: String,
latestUpdate: Updater.LatestRelease?,
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
scrollOffset: () -> Int,
haptic: HapticFeedback
) {
val heroShape = RoundedCornerShape(36.dp)
val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) }
Box(
modifier = Modifier
.padding(horizontal = HomeRootSection.cardMargin, vertical = 6.dp)
.clip(heroShape)
.background(Brush.linearGradient(heroGradientColors))
.border(1.dp, Color.White.copy(alpha = 0.1f), heroShape)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "PurrfectSnap",
color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.ExtraBold, fontFamily = avenirNext,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 250f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.06f)
}
)
Text(
text = "By \u039eT\u039eRNAL",
color = Color.White.copy(alpha = 0.75f), fontSize = 14.sp, fontFamily = avenirNext,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 300f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.04f)
}
)
Text(
text = translation["hero_tagline"] ?: "",
color = Color.White.copy(alpha = 0.9f), fontSize = 15.sp, lineHeight = 20.sp, textAlign = TextAlign.Center,
modifier = Modifier.graphicsLayer {
alpha = (1f - ((scrollOffset() - 350f) / 120f)).coerceIn(0f, 1f)
translationY = (-scrollOffset() * 0.02f)
}
)
}
FlowRow(
modifier = Modifier.fillMaxWidth().graphicsLayer {
alpha = (1f - ((scrollOffset() - 400f) / 120f)).coerceIn(0f, 1f)
},
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge(translation.format("hero_build_label", "build" to it))
}
}
if (latestUpdate != null) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(translation["update_title"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Text(translation.format("update_content", "version" to latestUpdate.versionName), color = Color.White.copy(alpha = 0.82f), fontSize = 12.sp)
}
AnimatedContent(targetState = downloadState, label = "UpdateDownloadHero") { state ->
when (state) {
UpdateDownloader.DownloadState.IDLE,
UpdateDownloader.DownloadState.FAILED -> {
Button(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onUpdateAction() },
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
) { Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) }
}
UpdateDownloader.DownloadState.DOWNLOADING -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
CircularProgressIndicator(progress = { downloadProgress }, modifier = Modifier.size(28.dp), color = Color.White)
Text("${(downloadProgress * 100).toInt()}%", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
UpdateDownloader.DownloadState.COMPLETED -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.Check, contentDescription = null, tint = Color(0xFFA3F0C2))
Text(translation["update_ready_label"] ?: "", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f)),
tonalElevation = 0.dp, shadowElevation = 0.dp
) {
val unifiedButtonWidth = 180.dp
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.12f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
modifier = Modifier.width(unifiedButtonWidth).height(46.dp),
tonalElevation = 0.dp, shadowElevation = 0.dp
) {
Row(
modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) {
LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isPurrAuraActive) translation["purr_aura_active_label"] ?: "" else translation["purr_aura_inactive_label"] ?: "",
color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp
)
}
}
OutlinedButton(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onAboutClick() },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(containerColor = Color.White.copy(alpha = 0.06f), contentColor = Color.White),
modifier = Modifier.width(unifiedButtonWidth).height(46.dp)
) {
Icon(Icons.Filled.Info, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation.getOrNull("about_meet_team_button") ?: "About Us", fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.10f))
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val androidContext = context.androidContext
Button(
modifier = Modifier.weight(1f).height(44.dp),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://purrfectsnap.me", context.translation["toast_open_link_failed"]) },
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
PurrfectMarqueeText(text = "Site", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color(0xFF1B152E))
}
}
OutlinedButton(
modifier = Modifier.weight(1f).height(44.dp),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
contentPadding = PaddingValues(horizontal = 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
PurrfectMarqueeText(text = translation["github_button"] ?: "", style = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Bold), color = Color.White)
}
}
ExternalLinkIcon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) },
tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f),
haptic = haptic
)
}
}
}
}
}
val haptic = LocalHapticFeedback.current
val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) }
val prefs = remember { context.sharedPreferences }
val allQuickTileNames = remember(cards) { cards.keys.map { it.first } }
val selectedTiles = rememberAsyncMutableStateList(defaultValue = allQuickTileNames) {
val storedTiles = context.database.getQuickTiles().filter { it.isNotBlank() }
val hasInitialized = prefs.getBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, false)
when {
storedTiles.isNotEmpty() -> {
if (!hasInitialized) prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
storedTiles
}
hasInitialized -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
allQuickTileNames
}
}
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
}
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
val scrollState = rememberScrollState()
var showQuickActionsMenu by rememberSaveable { mutableStateOf(false) }
var showChangelogDialog by rememberSaveable { mutableStateOf(false) }
var changelogText by rememberSaveable { mutableStateOf<String?>(null) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
var announcementsLoading by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.value) { routes.navigation?.globalScrollOffset = scrollState.value }
val handleUpdateAction: () -> Unit = {
latestUpdate?.let { latest ->
val abiName = android.os.Build.SUPPORTED_ABIS.firstNotNullOfOrNull {
when (it) { "arm64-v8a" -> "arm64"; "armeabi-v7a" -> "armv7"; else -> null }
}
if (latest.workflowId != null) {
if (abiName != null) {
val artifactName = "purrfectsnap-${if (abiName == "arm64") "armv8" else "armv7"}-debug"
UpdateDownloader.downloadAndInstall(context, "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip", "$artifactName.zip", coroutineScope)
}
} else {
abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url ->
UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope)
}
}
}
}
fun loadChangelog() {
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
changelogError = e.message ?: "Failed to fetch"
changelogLoading = false
}
}
}
}
fun loadAnnouncements() {
if (announcementsText != null) return
announcementsLoading = true
coroutineScope.launch(Dispatchers.IO) {
runCatching {
OkHttpClient().newCall(Request.Builder().url(announcementsUrl).build()).execute().use { it.body?.string() ?: "" }
}
.onSuccess { withContext(Dispatchers.Main) { announcementsText = it; announcementsLoading = false } }
.onFailure { withContext(Dispatchers.Main) { announcementsLoading = false } }
}
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
Box(modifier = Modifier.fillMaxSize().background(HomeRootSection.pageBackgroundGradient)) {
val focusFactor by remember(scrollState.value) {
derivedStateOf { (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
}
val stickyBrandingAlpha by remember(scrollState.value) {
derivedStateOf { ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f) }
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val headerHeight = lerp(54.dp, 56.dp, focusFactor)
val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
val topCorners = lerp(26.dp, 0.dp, focusFactor)
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) {
val refractiveColor = remember { Color(0xFF241F52) }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = containerTopPadding)
.height(internalTopPadding + headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
1.0f to Color.Transparent
)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = containerTopPadding)
.headerHeightTracker { controlsHeight = it }
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = focusFactor * 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = focusFactor * 0.4f)
)
)
val tr = topCorners.toPx()
val br = bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
uPath.apply {
moveTo(0f, 0f)
lineTo(0f, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 180f, -90f, false)
lineTo(size.width - br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 90f, -90f, false)
lineTo(size.width, 0f)
}
drawPath(uPath, brush, style = Stroke(strokeWidth))
} else if (focusFactor > 0.01f) {
borderPath.reset()
borderPath.apply {
moveTo(tr, 0f)
lineTo(size.width - tr, 0f)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * tr, 0f, size.width, 2 * tr), 270f, 90f, false)
lineTo(size.width, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 0f, 90f, false)
lineTo(br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 90f, 90f, false)
lineTo(0f, tr)
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2 * tr, 2 * tr), 180f, 90f, false)
}
drawPath(borderPath, brush, style = Stroke(strokeWidth))
}
},
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = bottomCorners, bottomEnd = bottomCorners),
color = Color(0xFF1B152E).copy(alpha = focusFactor * 0.95f)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = internalTopPadding)
.padding(horizontal = 16.dp)
.height(headerHeight)
) {
Text(
text = "PurrfectSnap",
color = Color.White.copy(alpha = stickyBrandingAlpha),
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
modifier = Modifier.align(Alignment.Center)
)
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
verticalAlignment = Alignment.CenterVertically
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
}
}
}
}
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = routes.bottomPadding + 4.dp)) {
Spacer(Modifier.height(controlsHeight + containerTopPadding))
AphelionHeroSection(
versionName = BuildConfig.VERSION_NAME,
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,
scrollOffset = { scrollState.value },
haptic = haptic
)
Spacer(Modifier.height(12.dp))
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActions") { hasQuickActions ->
Surface(
modifier = Modifier.padding(horizontal = HomeRootSection.cardMargin, vertical = 10.dp),
shape = RoundedCornerShape(34.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier.fillMaxWidth().background(Brush.linearGradient(quickActionsGradientColors)).padding(horizontal = 24.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!hasQuickActions) {
Text(translation["quick_actions_title"] ?: "", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.85f))
Spacer(Modifier.height(24.dp))
Icon(Icons.Outlined.Widgets, contentDescription = null, modifier = Modifier.size(72.dp), tint = Color.White)
Spacer(Modifier.height(16.dp))
Text(translation["quick_actions_empty_title"] ?: "", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(Modifier.height(20.dp))
Button(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text(translation["quick_actions_add_tile_button"] ?: "")
}
} else {
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 18.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(translation["quick_actions_title"] ?: "", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
Text(translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()), fontSize = 13.sp, color = Color.White.copy(alpha = 0.75f))
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showQuickActionsMenu = true },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage), contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(translation["quick_actions_manage_button"] ?: "")
}
}
var gridIsVisible by remember { mutableStateOf(false) }
var animationPhase by remember { mutableIntStateOf(1) }
LaunchedEffect(gridIsVisible) {
if (gridIsVisible) {
delay(600); animationPhase = 2
delay(1200); animationPhase = 3
}
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth().onGloballyPositioned { coords ->
val windowHeight = context.androidContext.resources.displayMetrics.heightPixels
val posY = coords.localToWindow(Offset.Zero).y
if (posY > 0 && posY < windowHeight * 0.95f) gridIsVisible = true
}
) {
val columns = (maxWidth / 110.dp).toInt().coerceIn(2, 4)
FlowRow(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.spacedBy(12.dp),
maxItemsInEachRow = columns
) {
selectedTiles.forEach { name ->
val cardEntry = cards.entries.find { it.key.first == name } ?: return@forEach
val interactionSource = remember { MutableInteractionSource() }
val animatedIconSize by animateDpAsState(
targetValue = if (animationPhase >= 2) 28.dp else 44.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
label = "iconShrink"
)
Surface(
modifier = Modifier.width(100.dp).aspectRatio(1.05f).scaleOnPress(interactionSource)
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); cardEntry.value(routes) },
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.3f), PurrfectPalette.glowSecondary.copy(alpha = 0.22f)))).clipToBounds()) {
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(cardEntry.key.second, contentDescription = null, tint = Color.White, modifier = Modifier.size(animatedIconSize))
Spacer(Modifier.height(8.dp))
PurrfectMarqueeText(
text = cardEntry.key.first,
style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
color = Color.White,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
if (showAnnouncementsDialog) {
AestheticDialog(
onDismissRequest = { showAnnouncementsDialog = false },
title = translation["announcements_dialog_title"] ?: "Announcements",
text = "", icon = Icons.Filled.Notifications,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showAnnouncementsDialog = false },
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showChangelogDialog) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "", icon = Icons.Filled.Info,
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
onDismiss = { showChangelogDialog = false },
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (changelogLoading) CircularProgressIndicator(color = Color.White)
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
selectedQuickActions = selectedTiles,
onDismiss = { showQuickActionsMenu = false },
onSave = { newList ->
val removed = selectedTiles.filter { it !in newList }
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
selectedTiles.clear(); selectedTiles.addAll(newList)
context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) }
showQuickActionsMenu = false
},
translation = translation
)
}
}

View File

@@ -0,0 +1,180 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
isRefreshing = true
coroutineScope.launch(Dispatchers.IO) {
val readerResult = runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
val filteredLogs = (0 until reader.lineCount).mapNotNull { index ->
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
}
withContext(Dispatchers.Main) {
visibleLogs.clear()
visibleLogs.addAll(filteredLogs)
if (visibleLogs.isNotEmpty()) {
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
}
isRefreshing = false
}
}
}
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
refreshLogs()
}
}
LaunchedEffect(Unit) {
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var showDropDown by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 12.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
clearLogsAndReload()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"] ?: "Clear", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
exportLogs()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"] ?: "Export", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
)
}
}

View File

@@ -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
)
}
}
}

View File

@@ -0,0 +1,346 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.ui.util.openFile
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val view = LocalView.current
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var controlsHeight by remember { mutableStateOf(100.dp) }
var showResetSetupDialog by remember { mutableStateOf(false) }
val sharedButtonColors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.07f),
contentColor = Color.White
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
val computedScrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
else listState.firstVisibleItemScrollOffset
}
}
LaunchedEffect(computedScrollOffset) {
routes.navigation?.globalScrollOffset = computedScrollOffset
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = context.translation["button.positive"],
dismissButtonText = context.translation["button.negative"],
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
) {
item {
Spacer(Modifier.height(controlsHeight))
}
item {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// THEME SWITCHER
GlassCard {
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
var localThemeId by remember { mutableStateOf(currentThemeId) }
Switch(
checked = localThemeId == "APHELION",
onCheckedChange = { isAphelion ->
val newId = if (isAphelion) "APHELION" else "LEGACY"
localThemeId = newId // Update UI instantly
AphelionHaptics.themeRevealTick(context, hapticFeedback)
// 1. Capture bitmap BEFORE theme change
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
// 2. Request Reveal
routes.navigation?.themeRevealState?.requestReveal(
newThemeId = newId,
originCenter = switchCenter,
bitmap = bitmap
)
// 3. Apply theme and persist
scope.launch {
kotlinx.coroutines.delay(50)
context.config.root.global.uiSettings.managerTheme.set(newId)
// Write to disk immediately on IO thread and finish
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
context.config.writeConfig()
}
writeJob.join() // Ensure it finishes its work before scope potentially closes
}
},
modifier = Modifier
.padding(end = 26.dp)
.onGloballyPositioned { coords ->
val rootPos = coords.positionInRoot()
switchCenter = androidx.compose.ui.geometry.Offset(
x = rootPos.x + coords.size.width / 2f,
y = rootPos.y + coords.size.height / 2f
)
},
colors = purrfectSwitchColors()
)
}
}
}
// ACTIONS
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
// UI SETTINGS
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
}
}
// UPDATES
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
}
}
// RESET SETUP
GlassCard {
RowTitle(title = translation["reset_setup_title"])
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
}
}
// MESSAGE LOGGER
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
}
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
}
}
// FRIEND NOTES
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
}
}
}
}
// DEBUG
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1f)) {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
}
}
}
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
}
}
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
}
}
}
}
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = computedScrollOffset,
enableMorph = true,
titleAlignment = Alignment.CenterHorizontally,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
routes.navigation?.openBottomBarCustomization = true
}) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
)
}
}

View File

@@ -0,0 +1,176 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.RemoveRedEye
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList
} else {
friendList.filter {
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
val filteredGroups = remember(groupList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
groupList
} else {
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
SocialHeader(
titles = titles,
pagerState = pagerState,
onTabSelected = { index ->
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
friendCount = friendList.size,
groupCount = groupList.size,
searchActive = searchActive,
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = searchShape,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, searchBorder),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, searchShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = searchHint,
tint = PurrfectPalette.textSecondary
)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontSize = 15.sp
),
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
modifier = Modifier.weight(1f)
) { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = searchHint,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
innerTextField()
}
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["clear_search_button_description"] ?: "Clear",
tint = Color.White
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
}
}
}

View File

@@ -0,0 +1,544 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.Intent
import android.graphics.drawable.ColorDrawable
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.task.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
val scrollState = rememberLazyListState()
val haptic = LocalHapticFeedback.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
val scope = rememberCoroutineScope()
var showConfirmDialog by remember { mutableStateOf(false) }
var alsoDeleteFiles by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchActiveTasks(this)
}
DisposableEffect(Unit) {
onDispose {
taskSelection.clear()
}
}
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
fetchActiveTasks(scope)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val 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()
)
}
// The "Structured Glass" Container (Dynamically Morphed)
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))
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = controlsHeight - 44.dp,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
}
}
items(activeTasks, key = { it.taskId }) { pendingTask ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(recentTasks, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Filled.PlaylistAddCheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = activeTasks.size.toString(),
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 12.sp
)
}
}
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
}
}
)
}
if (showConfirmDialog) {
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, scope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
@Composable
internal fun TasksRootSection.AphelionTasksEmptyState(text: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Box(
modifier = Modifier
.size(58.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
)
),
CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = text,
tint = Color.White
)
}
}
Text(
text = text,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
color = Color.White
)
}
}
@Composable
internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
var taskStatus by remember { mutableStateOf(task.status) }
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) }
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
var documentFileMimeType by remember { mutableStateOf("") }
var isDocumentFileReadable by remember { mutableStateOf(true) }
val docVal = task.extra?.toUri()
val documentFile by rememberAsyncMutableState(
defaultValue = null as DocumentFile?,
keys = arrayOf(taskStatus.name)
) {
if (docVal == null) null
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
documentFileMimeType = type ?: ""
isDocumentFileReadable = canRead()
}
}
val listener = remember { PendingTaskListener(
onStateChange = { taskStatus = it },
onProgress = { label, progress ->
taskProgressLabel = label
taskProgress = progress
}
) }
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
val haptic = LocalHapticFeedback.current
val isActive = pendingTask != null && !taskStatus.isFinalStage()
fun toggleSelection() {
if (isSelected) {
taskSelection.removeIf { it.first == task }
return
}
taskSelection.add(task to documentFile)
}
fun openFile() {
if (!isDocumentFileReadable || documentFile == null) return
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile!!.uri, documentFile!!.type)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
})
}.onFailure {
context.log.error("Failed to open file ${documentFile?.uri}", it)
context.shortToast(translation["failed_to_open_file"] ?: "Failed to open file")
}
}
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
)
}
.let {
if (isSelected) {
it.border(2.dp, PurrfectPalette.glowSecondary, RoundedCornerShape(22.dp)).clip(RoundedCornerShape(22.dp))
} else it
}
val chipLabel = when {
isActive -> translation.getOrNull("task_sending") ?: "Sending"
taskStatus == TaskStatus.SUCCESS -> null
taskStatus == TaskStatus.FAILURE -> translation.getOrNull("task_failed") ?: "Failed"
taskStatus == TaskStatus.CANCELLED -> translation.getOrNull("task_cancelled") ?: "Cancelled"
else -> taskStatus.name.lowercase().replaceFirstChar { it.titlecase() }
}
val chipIcon = when {
isActive -> null
taskStatus == TaskStatus.SUCCESS -> Icons.Filled.Check
taskStatus == TaskStatus.FAILURE -> Icons.Filled.WarningAmber
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
else -> Icons.Filled.Info
}
val chipColors = when {
isActive -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.08f),
labelColor = Color.White
)
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
labelColor = Color.White
)
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
containerColor = Color.White.copy(alpha = 0.06f),
labelColor = PurrfectPalette.textSecondary
)
else -> AssistChipDefaults.assistChipColors()
}
val countdownText = if (isActive) {
taskProgressLabel?.let { label ->
Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label
}
} else null
val cardShape = RoundedCornerShape(22.dp)
Surface(
modifier = cardModifier,
shape = cardShape,
color = Color.Transparent,
border = BorderStroke(1.dp, if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) else SolidColor(Color.White.copy(alpha = 0.1f)))
) {
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
var loadFailed by remember { mutableStateOf(false) }
val doc = documentFile
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
val imageRequest = ImageRequest.Builder(context.androidContext)
.data(doc.uri)
.cacheKey(doc.uri.toString())
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
.build()
Image(
painter = rememberAsyncImagePainter(
model = imageRequest,
imageLoader = context.imageLoader,
onState = { state ->
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
}
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
)
} else {
when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
}
}
}
Column(modifier = Modifier.weight(1f)) {
if (task.type == TaskType.SCHEDULED_SEND) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", style = MaterialTheme.typography.labelMedium, color = PurrfectPalette.textSecondary)
Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White)
task.author?.takeIf { it != "null" }?.let { recipients ->
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Filled.People, contentDescription = null, modifier = Modifier.size(16.dp).padding(top = 2.dp), tint = PurrfectPalette.textSecondary)
recipients.split(", ").let { list ->
Text(list.joinToString(", "), style = MaterialTheme.typography.bodyMedium, color = Color.White, lineHeight = 20.sp)
}
}
}
}
} else {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White)
task.author?.takeIf { it != "null" }?.let {
Spacer(modifier = Modifier.width(5.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
}
}
Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary)
}
Column(modifier = Modifier.padding(top = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
chipLabel?.let { label ->
val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) {
{ Icon(Icons.Filled.Timer, contentDescription = null, modifier = Modifier.size(16.dp)) }
} else if (isActive) {
countdownText?.let { countdown -> { Text(countdown, style = MaterialTheme.typography.labelSmall) } }
} else chipIcon?.let { icon -> { Icon(icon, contentDescription = null) } }
val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) {
translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText"
} else label
AssistChip(onClick = {}, enabled = false, leadingIcon = leadingIcon, label = { Text(displayLabel) }, colors = chipColors)
}
if (!taskStatus.isFinalStage()) {
if (!isActive) {
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
}
if (taskProgress != -1 && taskProgressLabel == null) {
LinearProgressIndicator(
progress = { taskProgress.toFloat() / 100f },
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),
color = PurrfectPalette.glowSecondary, trackColor = Color.White.copy(alpha = 0.12f)
)
}
if (!isActive) {
task.extra?.takeIf { it.isNotEmpty() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) }
}
}
}
}
Column {
if (isActive) {
FilledIconButton(
onClick = {
runCatching { pendingTask?.cancel() }.onFailure { throwable ->
context.log.error("Failed to cancel task $pendingTask", throwable)
}
},
colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), contentColor = Color.White)
) { Icon(Icons.Filled.Close, contentDescription = "Cancel") }
} else if (taskStatus == TaskStatus.SUCCESS) {
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(250)) + scaleIn(tween(300)),
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150))
) {
Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White)
}
}
} else {
when (taskStatus) {
TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B))
TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B))
else -> {}
}
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
);
}
"""
}

View File

@@ -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,

View File

@@ -40,7 +40,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
@@ -67,6 +66,7 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Overlay
import java.io.File
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
@@ -1093,7 +1093,7 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(
StandardDialog(
onDismissRequest = {
customCoordinatesDialog = false
},
@@ -1398,7 +1398,7 @@ class AlertDialogs(
// Add/Edit message dialog
if (showAddDialog) {
Dialog(
StandardDialog(
onDismissRequest = { showAddDialog = false },
properties = DialogProperties(
usePlatformDefaultWidth = false

View File

@@ -4,12 +4,28 @@ 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.addCallback
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,9 +35,12 @@ 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
@@ -33,6 +52,12 @@ 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 +108,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 +146,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 +186,75 @@ 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() }
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
) {
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 +403,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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -33,13 +33,15 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("270").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.3").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("298").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.
rootProject.ext.set(
"buildHash",
properties["debug_build_hash"]
?: java.security.SecureRandom()
(properties["debug_build_hash"] as String?)
?: "${rootProject.ext["appVersionCode"]}_${java.security.SecureRandom()
.nextLong(Long.MAX_VALUE / 1000L, Long.MAX_VALUE)
.toString(16)
.toString(16)}"
)

View File

@@ -1,3 +1,115 @@
## v1.5.3
- New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue)
- Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay
- New: Translucent Dialogs & refreshed animation
- Fix: Adjusted Snap Preview Location
## v1.5.2
- New: Continuous snap sender feature!
- Fix: Snap send failure for E2E Chats
## v1.5.1
- Fix: Splitting issue for video snaps sent through gallery media send override!
- New: Toggle to turn off/on splitting for video snaps sent through send override
- Fix: Aphelion task page layout optimization(tq to Kaladin)
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI
## v1.4.9
- Fix: Force AMOLED Theme for newer versions of Snapchat
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)
## v1.4.8
- Fix: Crash Issues for some devices
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
- Fix: Both side call recording for newer versions of snapchat
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
## v1.4.6
- Fix: Half Swipe Notifications for newer versions of snapchat
- Fix: No Config import/export button if Aphelion theme is turned off
## v1.4.5
- New: Spoof Snap Score Locally(tq to RSR)
- New: Video Recording Timer(tq to RSR)
- New: Auto Skip(tq to RSR)
- Fix: Processing failed to save snaps(tq to RSR)
- Fix: Story counter & story source for newer versions of snapchat(tq to RSR)
- Fix: Features page using Aphelion scroll even when Aphelion is not in use(tq to Kaladin)
- Fix: Aphelion Task page not showing the thumbnails of the completed tasks(tq to Kaladin)
- Fix: Tasks page not showing the merge button when Aphelion is not in use(tq to Kaladin)
- Fix: Features and its sub pages scroll state is remembered infintely, causing the pages to load where they were exited even after the app is closed(tq to Kaladin)
- New: Expanded Friend Mutation Observer to include the new layout when Aphelion is in use(tq to Kaladin)
- New: Theme Transition Animation(tq to Kaladin)
- Fix: Friend feed menu button overlapping for group chats
- Fix: Duplicate opera download & mark snaps as seen buttons
## v1.4.1
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
- Fix: Story Counter/Story Source position(tq to AhmedRaza)
- Fix: Better Location Overlay Icon for newer Snapchat versions
- Fix: Double tap to mark chat as read for newer Snapchat versions
- New: Mark Chat as Read(Friend feed menu)
- Fix: Opera Download button duplicating
- Fix: All friends getting automatically added even after unselecting in the social tab
- Fix: Spotlight Creator Info
## v1.4.0
- Fix: Download profile picture button showing in all pages
- Fix: Opera Download button & Mark Snaps as seen for newer snapchat versions
- Fix: Crash issues
- Fix: Sync Binder died
- Fix: Random friends showing up in Social tab
- Fix: Untranslated strings in the message preview section(en_US only for now)
- Fix: Show First Created Username
- Fix: Call recording starting automatically when taking snaps/videos
- Fix: Snap Downloading issue for some devices
- New: Story Snap Batch Download(tq to RSR)
- New: Aphelion Theme!(tq to Kaladin)
- Fix: Monochrome icon of PurrfectSnap(tq to Kaladin)
## v1.3.9
- Fix: Improve ads & metrics blocking
- Fix: Hide Typing Notifications & Hide Bitmoji Presence
- Fix: Many fixes & improvements
## v1.3.8
- Fix: Download profile picture button shape
- New: Auto Patcher(Jingmatrix Lspatch) updated to v0.8
- Fix: Video playback rate slider mobility
- New: Redesign Convert Message dialog
- Fix: Media File Picker issues
- Fix: REQUEST_NOT_SUCCESSFUL issues while updating PurrfectSnap for some devices
## v1.3.6
- Fix: Crash issues for some devices
- Fix: Scroll state
- New: Redesign profile picture downloader & spotlight comments username dialog
- New: Change the profile picture download button to an icon
- New: Redesign playback slider & saved coordinates buttons
- Fix: Add missing translations(en_US only for now, all languages to be supported in the next update)
- Fix: Deadosobjectexception
## v1.3.5
- New: Added support for Snap versions upto v13.81(server side update, requires v1.3.2 and above)
- New: Migration from libxposed API to YukiHook
- New: Switch to kavaref
- Fix: better location overlay crash
- Fix: better location icon position for newer snap versions
- New: Story counter(tq to RSR)
- New: Story Source(tq to RSR)
- Fix: Snaps getting saved even if the option is unchecked(tq to AhmedRaza)
- Fix: Chat edit history UI(tq to AhmedRaza)
- Fix: Media quality(tq to RSR)
- New: Remove support for Android 9 and 10
## v1.3.2
- ⚠️ ALL USERS ARE REQUESTED TO UPDATE TO THIS VERSION AS SOON AS POSSIBLE!⚠️
- Fix: Crucial fixes
- Fix: Media File Picker and added mapper(tq to RSR)
## v1.3.0
- New: Bulk Manage Friend Requests(tq to RSR)
- Fix: Bulk Action Progress Dialogs(tq to RSR)

View File

@@ -46,6 +46,8 @@ dependencies {
implementation(libs.okhttp)
implementation(libs.androidx.documentfile)
implementation(libs.rhino)
implementation(libs.kavaref.core)
implementation(libs.kavaref.extension)
implementation(libs.rhino.android) {
exclude(group = "org.mozilla", module = "rhino-runtime")
}

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "هل أنت متأكد أنك تريد إزالة المهام المحددة؟",
"remove_all_tasks_title": "هل أنت متأكد أنك تريد إزالة جميع المهام؟",
"delete_files_option": "حذف الملفات أيضاً",
"delete_files_option_hint": "إزالة الملفات المحملة من الجهاز أيضاً",
"remove_selected_tasks_confirm": "إزالة {count} مهام؟",
"remove_all_tasks_confirm": "إزالة جميع المهام؟"
},
@@ -1099,6 +1100,18 @@
"name": "معلومات الوسائط السريعة في أوبرا",
"description": "يعرض معلومات مفيدة عن الوسائط مثل تاريخ الإنشاء في قائمة سياق عارض أوبرا"
},
"story_counter": {
"name": "عداد القصص",
"description": "يعرض عداداً (مثل 1/10) عند مشاهدة القصص"
},
"story_source_indicator": {
"name": "مؤشر مصدر القصة",
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
},
"story_snap_jump": {
"name": "تخطي تلقائي",
"description": "يضيف زر تخطي للانتقال إلى أي سناب في القصة. اضغط على أيقونة التخطي أو العداد لفتح نافذة القفز"
},
"old_bitmoji_selfie": {
"name": "سيلفي Bitmoji القديم",
"description": "يعيد سيلفي Bitmoji من إصدارات Snapchat القديمة"
@@ -1146,6 +1159,16 @@
"settings_menu": {
"name": "قائمة الإعدادات",
"description": "اختر بين تخطيطات قائمة الإعدادات الجديدة والقديمة"
},
"spoof_snap_score": {
"name": "تزييف نقاط سناب شات",
"description": "يقوم بتزييف عدد نقاط سناب شات (المحلية فقط)",
"properties": {
"custom_snap_score": {
"name": "النقاط المخصصة",
"description": "تعيين نقاط السناب شات الوهمية (أقصى عدد هو 9,999,999)"
}
}
}
}
},
@@ -1915,6 +1938,10 @@
"hevc_recording": {
"name": "تسجيل HEVC",
"description": "يستخدم ترميز HEVC (H.265) لتسجيل الفيديو"
},
"video_record_timer": {
"name": "مؤقت تسجيل الفيديو",
"description": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
}
}
},
@@ -2767,7 +2794,6 @@
"show_chat_edit_history": "عرض سجل تحرير الدردشة",
"convert_message": "تحويل الرسالة"
},
"chat_wallpaper_downloader": {
"download_button": "تنزيل خلفية الدردشة"
},
@@ -3028,7 +3054,15 @@
"title": "تنزيل وسائط dash",
"download_all": "تنزيل الكل",
"segment_text": "جزء {from} - {to}"
}
},
"story_snap_dialog": {
"title": "تنزيل سنابات القصة",
"select_all": "تحديد الكل",
"deselect_all": "إلغاء التحديد",
"snap_item": "السناب {index} من {total}"
},
"batch_download_complete_toast": "تم تنزيل جميع السنابات",
"batch_download_jump_failed_toast": "تعذر الانتقال للسناب التالي. تأكد من أن عرض القصة مرئي."
},
"streaks_reminder": {
"notification_title": "الستريك (Streaks)",

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "আপনি কি নির্বাচিত টাস্কগুলো সরাতে চান?",
"remove_all_tasks_title": "আপনি কি সব টাস্ক সরাতে চান?",
"delete_files_option": "ফাইলগুলোও ডিলিট করুন",
"delete_files_option_hint": "স্টোরেজ থেকে মূল ফাইলগুলো স্থায়ীভাবে সরান",
"remove_selected_tasks_confirm": "{count} টি টাস্ক সরাবেন?",
"remove_all_tasks_confirm": "সব টাস্ক সরাবেন?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Er du sikker på, at du vil fjerne valgte opgaver?",
"remove_all_tasks_title": "Er du sikker på, at du vil fjerne alle opgaver?",
"delete_files_option": "Slet også filer",
"delete_files_option_hint": "Fjern også downloadede filer fra enheden",
"remove_selected_tasks_confirm": "Fjern {count} opgaver?",
"remove_all_tasks_confirm": "Fjern alle opgaver?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Möchtest du die ausgewählten Aufgaben wirklich entfernen?",
"remove_all_tasks_title": "Möchtest du wirklich alle Aufgaben entfernen?",
"delete_files_option": "Auch Dateien löschen",
"delete_files_option_hint": "Heruntergeladene Dateien auch vom Gerät entfernen",
"remove_selected_tasks_confirm": "{count} Aufgaben entfernen?",
"remove_all_tasks_confirm": "Alle Aufgaben entfernen?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"delete_files_option": "Also delete files",
"delete_files_option_hint": "Also remove downloaded files from device",
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
@@ -1189,6 +1190,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -3638,4 +3647,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -247,7 +247,7 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
@@ -264,6 +264,8 @@
"read_logs_failed_toast": "Failed to read logs!"
},
"home_settings": {
"ui_theme_title": "UI Theme",
"settings_ui_theme": "Aphelion Theme",
"actions_title": "Actions",
"message_logger_title": "Message Logger",
"debug_title": "Debug",
@@ -334,12 +336,14 @@
"summary_active": "{active} active \u00b7 {recent} recent",
"summary_idle": "Idle \u00b7 {recent} recent",
"running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"clear_button_description": "Clear tasks",
"failed_to_open_file": "Failed to open file",
"merge_files_toast": "Merging {count} files",
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"delete_files_option": "Also delete files",
"delete_files_option_hint": "Permanently remove the original files from storage",
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
@@ -358,7 +362,7 @@
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Search and manage features"
"subtitle": "Explore and manage premium features"
},
"bypass_status": {
"active": "PurrAura Active",
@@ -388,8 +392,43 @@
"friends_empty_title": "No friends added yet",
"groups_empty_title": "No groups synced yet",
"streaks_expiration_short": "{hours}h",
"social_tagline": "Manage scopes, streaks, and previews",
"social_empty_hint": "Tap the + button to sync friends or groups."
"social_tagline": "Manage friends, groups, and streaks",
"social_empty_hint": "Tap the + button to sync friends or groups.",
"messaging_preview": {
"bridge_connection_failed": "Failed to connect to bridge. Make sure Snapchat is running in the background",
"bridge_connection_error": "Failed to connect to bridge. Make sure Snapchat is running in the background",
"bridge_init_failed": "Failed to initialize messaging bridge. Make sure Snapchat is running in the background",
"message_fetch_failed": "Failed to fetch messages",
"no_message_hint": "No message",
"sender_unknown": "Unknown",
"sender_you": "You",
"sender_friend": "Friend",
"subtitle": "Hold to select",
"actions_title": "Conversation Actions",
"choose_message_types_subtitle": "Choose message types",
"choose_message_type_subtitle": "Choose message types",
"save_selection_option": "Save Selection",
"save_all_option": "Save All",
"save_selected_messages_subtitle": "Save selected messages",
"save_by_content_type_subtitle": "Save by content type",
"unsave_selection_option": "Unsave Selection",
"unsave_all_option": "Unsave All",
"unsave_selected_messages_subtitle": "Unsave selected messages",
"unsave_by_content_type_subtitle": "Unsave by content type",
"unsave_by_content_ttype_subtitle": "Unsave by content type",
"mark_selection_as_seen_option": "Mark selected Snap as seen",
"mark_all_as_seen_option": "Mark all Snaps as seen",
"mark_all_as_as_seen_option": "Mark all Snaps as seen",
"mark_as_seen_subtitle": "Marks snaps as seen",
"delete_selection_option": "Delete Selection",
"delete_all_option": "Delete All",
"delete_selected_messages_subtitle": "Delete selected messages",
"delete_by_content_type_subtitle": "Delete by content type",
"processed_message_toast": "Processed {count} messages",
"processed_messages_toast": "Processed {count} messages",
"processed_messages_text": "Processed {count}",
"close_button_description": "Clear selection"
}
},
"manage_scope": {
"manage_scope_title": "Manage",
@@ -979,6 +1018,10 @@
"name": "Opera Download Button",
"description": "Adds a download button on the top right corner when viewing a Snap.\nLong press on buttons will force download"
},
"story_snap_list_download": {
"name": "Story Snap List Download",
"description": "When viewing a multi-snap story, shows a dialog to select which snaps to download"
},
"download_context_menu": {
"name": "Download Context Menu",
"description": "Allows you to download/preview messages from a conversation or a story using the context menu.\nLong press on buttons will force download"
@@ -1099,6 +1142,18 @@
"name": "Opera Media Quick Info",
"description": "Shows useful information of media such as creation date in opera viewer context menu"
},
"story_counter": {
"name": "Story Counter",
"description": "Shows a counter (e.g. 1/10) when viewing stories"
},
"story_source_indicator": {
"name": "Story Source Indicator",
"description": "Shows an icon indicating whether the snap was taken from the camera or uploaded from the gallery\nOnly works with friend stories"
},
"story_snap_jump": {
"name": "Auto Skip",
"description": "Adds a skip button to jump to any snap in a story. Tap the skip icon or counter to open the jump dialog"
},
"old_bitmoji_selfie": {
"name": "Old Bitmoji Selfie",
"description": "Brings back the Bitmoji selfies from older Snapchat versions"
@@ -1146,6 +1201,16 @@
"settings_menu": {
"name": "Settings Menu",
"description": "Choose between the new and legacy settings menu layouts"
},
"spoof_snap_score": {
"name": "Spoof Snap Score",
"description": "Spoof your Snap Score (local only)",
"properties": {
"custom_snap_score": {
"name": "Custom Snap Score",
"description": "The custom Snap Score you want to display (max 9,999,999)"
}
}
}
}
},
@@ -1189,6 +1254,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -1915,6 +1988,10 @@
"hevc_recording": {
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
},
"video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
}
}
},
@@ -2121,6 +2198,10 @@
"force_message_encryption": {
"name": "Force Message Encryption",
"description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected"
},
"hide_conversation_toolbox_ui": {
"name": "Hide Conversation Toolbox UI",
"description": "Hides the PurrfectSnap conversation toolbox button that appears in Snapchat when End-To-End Encryption is enabled"
}
}
},
@@ -2230,6 +2311,7 @@
"stealth": "\ud83d\udc7b Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
"mark_stories_as_seen_locally": "\ud83d\udc40 Mark Stories as seen locally",
"conversation_info": "\ud83d\udc64 Conversation Info",
@@ -2246,11 +2328,23 @@
"schedule_failed": "Scheduled snap failed",
"schedule_cancelled_for": "Cancelled for {name}",
"device_model": {
"samsung_s25_ultra": "Samsung Galaxy S25 Ultra",
"google_pixel_10_pro": "Google Pixel 10 Pro",
"oneplus_13": "OnePlus 13",
"xiaomi_15_ultra": "Xiaomi 15 Ultra",
"null": "Device Default"
"none": "Device Default",
"random": "Random",
"Pixel 8 Pro": "Pixel 8 Pro",
"Pixel 9 Pro XL": "Pixel 9 Pro XL",
"Pixel 10": "Pixel 10",
"Pixel 10 Pro": "Pixel 10 Pro",
"Pixel 10 Pro XL": "Pixel 10 Pro XL",
"Pixel 10 Pro Fold": "Pixel 10 Pro Fold",
"Galaxy S23 Ultra": "Galaxy S23 Ultra",
"Galaxy S24 Ultra": "Galaxy S24 Ultra",
"Galaxy S25 Ultra": "Galaxy S25 Ultra",
"OnePlus 15": "OnePlus 15",
"OnePlus Open": "OnePlus Open",
"Xiaomi 15 Ultra": "Xiaomi 15 Ultra",
"OPPO Find X9 Pro": "OPPO Find X9 Pro",
"vivo X100 Pro": "vivo X100 Pro",
"realme GT 6": "realme GT 6"
},
"settings_menu": {
"default": "Default",
@@ -2339,16 +2433,16 @@
"custom_android_id": {
"null": "Use real Android ID"
},
"add_friend_source_spoof": {
"added_by_username": "By Username",
"added_by_mention": "By Mention",
"added_by_group_chat": "By Group Chat",
"added_by_qr_code": "By QR Code",
"added_by_community": "By Community",
"added_by_quick_add": "By Quick Add (high risk of being banned)",
"added_by_spotlight": "By Spotlight",
"null": "Don't spoof source"
},
"add_friend_source_spoof": {
"added_by_username": "By Username",
"added_by_mention": "By Mention",
"added_by_group_chat": "By Group Chat",
"added_by_qr_code": "By QR Code",
"added_by_community": "By Community",
"added_by_quick_add": "By Quick Add (high risk of being banned)",
"added_by_spotlight": "By Spotlight",
"null": "Don't spoof source"
},
"custom_streaks_expiration_format": {
"null": "System Default"
},
@@ -2428,6 +2522,10 @@
"remove_audio_note_duration": "Remove Audio Note Duration",
"remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability"
},
"mark_snap_as_seen_processing_mode": {
"limit": "Limit Per Run",
"complete": "Complete Queue"
},
"hide_ui_components": {
"hide_profile_call_buttons": "Remove Profile Call Buttons",
"hide_chat_call_buttons": "Remove Chat Call Buttons",
@@ -2704,6 +2802,8 @@
}
},
"friend_menu_option": {
"mark_chat_as_read": "Mark Chat as Read",
"mark_chat_as_read_toast": "Marked chat as read!",
"mark_snaps_as_seen": "Mark Snaps as seen",
"mark_stories_as_seen_locally": "Mark Stories as seen locally",
"preview": "Preview",
@@ -2766,7 +2866,6 @@
"show_chat_edit_history": "Show Chat Edit History",
"convert_message": "Convert Message"
},
"chat_wallpaper_downloader": {
"download_button": "Download Chat Wallpaper"
},
@@ -2987,9 +3086,20 @@
"profile_picture_downloader": {
"button": "Download Profile Picture",
"title": "Profile Picture Downloader",
"subtitle": "Choose which profile image to save",
"empty_state": "No profile pictures available. Please wait for the profile to load.",
"download_hint": "Tap to download",
"avatar_option": "Avatar",
"background_option": "Background"
},
"spotlight_comments_username_dialog": {
"title": "User Information",
"subtitle": "Spotlight comment profile details",
"username_label": "Username",
"display_name_label": "Display Name",
"user_id_label": "User ID",
"not_available": "Not available"
},
"call_start_confirmation": {
"dialog_title": "Start Call",
"dialog_message": "Are you sure you want to start a call?"
@@ -3027,7 +3137,15 @@
"title": "Download dash media",
"download_all": "Download All",
"segment_text": "Segment {from} - {to}"
}
},
"story_snap_dialog": {
"title": "Download story snaps",
"select_all": "Select All",
"deselect_all": "Deselect All",
"snap_item": "Snap {index} of {total}"
},
"batch_download_complete_toast": "All snaps downloaded",
"batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible."
},
"streaks_reminder": {
"notification_title": "Streaks",
@@ -3221,6 +3339,13 @@
"title": "Send media as",
"duration": "Duration: {duration}",
"saveable_snap_hint": "Make Snap saveable in the chat",
"single_send_hint": "Send as one snap",
"continuous_send_toggle": "Continuous snap sender",
"continuous_send_count_label": "Send count",
"continuous_send_count_placeholder": "Enter number of sends",
"continuous_send_hint": "This will send the same snap to the same recipient multiple times.",
"continuous_send_invalid_count": "Enter a valid send count greater than 0",
"continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.",
"unlimited_duration": "Unlimited",
"schedule": "Schedule",
"select_time": "Select time",
@@ -3228,6 +3353,9 @@
"select_date_first": "Please select a date",
"invalid_time": "Please select a future time"
},
"convert_message_dialog": {
"subtitle": "Choose how this message should be reshaped locally."
},
"spotlight_creator_info": {
"title": "Creator Info",
"close": "Close",
@@ -3321,10 +3449,10 @@
"search": {
"placeholder": "Search"
},
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"search_by": "Search by",
"since": "Since",
"until": "Until",
@@ -3339,7 +3467,7 @@
"started_typing": "Started typing",
"stopped_typing": "Stopped typing",
"started_speaking": "Started speaking",
"stopped_speaking": "Stopped speaking",
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"message_read": "Read message",
@@ -3499,6 +3627,8 @@
"cancel": "Cancel",
"documentation": "Documentation"
},
"include_saved_locations": "Include Saved Locations",
"include_saved_locations_description": "Export your saved location coordinates",
"common": {
"cancel": "Cancel",
"close": "Close",

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "¿Estás seguro de que quieres eliminar las tareas seleccionadas?",
"remove_all_tasks_title": "¿Estás seguro de que quieres eliminar todas las tareas?",
"delete_files_option": "También eliminar archivos",
"delete_files_option_hint": "También eliminar archivos descargados del dispositivo",
"remove_selected_tasks_confirm": "¿Eliminar {count} tareas?",
"remove_all_tasks_confirm": "¿Eliminar todas las tareas?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Oletko varma, että haluat poistaa valitut tehtävät?",
"remove_all_tasks_title": "Oletko varma, että haluat poistaa kaikki tehtävät?",
"delete_files_option": "Poista myös tiedostot",
"delete_files_option_hint": "Poista myös ladatut tiedostot laitteelta",
"remove_selected_tasks_confirm": "Poista {count} tehtävää?",
"remove_all_tasks_confirm": "Poista kaikki tehtävät?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Êtes-vous sûr de vouloir supprimer les tâches sélectionnées ?",
"remove_all_tasks_title": "Êtes-vous sûr de vouloir supprimer toutes les tâches ?",
"delete_files_option": "Supprimer aussi les fichiers",
"delete_files_option_hint": "Supprimer aussi les fichiers téléchargés de l'appareil",
"remove_selected_tasks_confirm": "Supprimer {count} tâches ?",
"remove_all_tasks_confirm": "Supprimer toutes les tâches ?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "क्या आप वाकई चयनित कार्यों को हटाना चाहते हैं?",
"remove_all_tasks_title": "क्या आप वाकई सभी कार्यों को हटाना चाहते हैं?",
"delete_files_option": "फ़ाइलें भी हटाएँ",
"delete_files_option_hint": "स्टोरेज से मूल फ़ाइलों को स्थायी रूप से हटाएँ",
"remove_selected_tasks_confirm": "{count} कार्य हटाएँ?",
"remove_all_tasks_confirm": "सभी कार्य हटाएँ?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Biztosan törölni szeretnéd a kijelölt feladatokat?",
"remove_all_tasks_title": "Biztosan törölni szeretnéd az összes feladatot?",
"delete_files_option": "Fájlok törlése is",
"delete_files_option_hint": "Az eredeti fájlok végleges eltávolítása a tárolóból",
"remove_selected_tasks_confirm": "{count} feladat törlése?",
"remove_all_tasks_confirm": "Összes feladat törlése?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Apakah Anda yakin ingin menghapus tugas yang dipilih?",
"remove_all_tasks_title": "Apakah Anda yakin ingin menghapus semua tugas?",
"delete_files_option": "Hapus juga file",
"delete_files_option_hint": "Hapus juga file yang diunduh dari perangkat",
"remove_selected_tasks_confirm": "Hapus {count} tugas?",
"remove_all_tasks_confirm": "Hapus semua tugas?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Sei sicuro di voler rimuovere le attività selezionate?",
"remove_all_tasks_title": "Sei sicuro di voler rimuovere tutte le attività?",
"delete_files_option": "Elimina anche i file",
"delete_files_option_hint": "Rimuovi anche i file scaricati dal dispositivo",
"remove_selected_tasks_confirm": "Rimuovere {count} attività?",
"remove_all_tasks_confirm": "Rimuovere tutte le attività?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "選択したタスクを削除しますか?",
"remove_all_tasks_title": "すべてのタスクを削除しますか?",
"delete_files_option": "ファイルも削除する",
"delete_files_option_hint": "ストレージから元のファイルを完全に削除する",
"remove_selected_tasks_confirm": "{count} 個のタスクを削除しますか?",
"remove_all_tasks_confirm": "すべてのタスクを削除しますか?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "선택한 작업을 제거하시겠습니까?",
"remove_all_tasks_title": "모든 작업을 제거하시겠습니까?",
"delete_files_option": "파일도 삭제",
"delete_files_option_hint": "저장소에서 원본 파일을 영구적으로 제거",
"remove_selected_tasks_confirm": "{count}개의 작업 제거?",
"remove_all_tasks_confirm": "모든 작업 제거?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "دڵنیایت دەتەوێت ئەرکە دیاریکراوەکان بسڕیتەوە؟",
"remove_all_tasks_title": "دڵنیایت دەتەوێت هەموو ئەرکەکان بسڕیتەوە؟",
"delete_files_option": "فایلەکانیش بسڕەوە",
"delete_files_option_hint": "فایلە ئەسڵییەکان بە هەمیشەیی لە کۆگەوە بسڕەوە",
"remove_selected_tasks_confirm": "{count} ئەرک بسڕدرێتەوە؟",
"remove_all_tasks_confirm": "هەموو ئەرکەکان بسڕدرێتەوە؟"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Vai tiešām vēlaties noņemt atlasītos uzdevumus?",
"remove_all_tasks_title": "Vai tiešām vēlaties noņemt visus uzdevumus?",
"delete_files_option": "Dzēst arī failus",
"delete_files_option_hint": "Noņemt arī lejupielādētos failus no ierīces",
"remove_selected_tasks_confirm": "Noņemt {count} uzdevumus?",
"remove_all_tasks_confirm": "Noņemt visus uzdevumus?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Er du sikker på at du vil fjerne valgte oppgaver?",
"remove_all_tasks_title": "Er du sikker på at du vil fjerne alle oppgaver?",
"delete_files_option": "Slett også filer",
"delete_files_option_hint": "Fjern permanent de originale filene fra lagring",
"remove_selected_tasks_confirm": "Fjerne {count} oppgaver?",
"remove_all_tasks_confirm": "Fjerne alle oppgaver?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Weet je zeker dat je de geselecteerde taken wilt verwijderen?",
"remove_all_tasks_title": "Weet je zeker dat je alle taken wilt verwijderen?",
"delete_files_option": "Ook bestanden verwijderen",
"delete_files_option_hint": "Ook gedownloade bestanden van apparaat verwijderen",
"remove_selected_tasks_confirm": "{count} taken verwijderen?",
"remove_all_tasks_confirm": "Alle taken verwijderen?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Czy na pewno chcesz usunąć wybrane zadania?",
"remove_all_tasks_title": "Czy na pewno chcesz usunąć wszystkie zadania?",
"delete_files_option": "Usuń również pliki",
"delete_files_option_hint": "Usuń również pobrane pliki z urządzenia",
"remove_selected_tasks_confirm": "Usunąć {count} zadań?",
"remove_all_tasks_confirm": "Usunąć wszystkie zadania?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Tem certeza que deseja remover as tarefas selecionadas?",
"remove_all_tasks_title": "Tem certeza que deseja remover todas as tarefas?",
"delete_files_option": "Excluir arquivos também",
"delete_files_option_hint": "Remover permanentemente os arquivos originais do armazenamento",
"remove_selected_tasks_confirm": "Remover {count} tarefas?",
"remove_all_tasks_confirm": "Remover todas as tarefas?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Ești sigur că vrei să elimini sarcinile selectate?",
"remove_all_tasks_title": "Ești sigur că vrei să elimini toate sarcinile?",
"delete_files_option": "Șterge și fișierele",
"delete_files_option_hint": "Elimină permanent fișierele originale din stocare",
"remove_selected_tasks_confirm": "Elimină {count} sarcini?",
"remove_all_tasks_confirm": "Elimină toate sarcinile?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Вы уверены, что хотите удалить выбранные задачи?",
"remove_all_tasks_title": "Вы уверены, что хотите удалить все задачи?",
"delete_files_option": "Также удалить файлы",
"delete_files_option_hint": "Также удалить загруженные файлы с устройства",
"remove_selected_tasks_confirm": "Удалить {count} задач?",
"remove_all_tasks_confirm": "Удалить все задачи?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Si prepričan, da želiš odstraniti izbrana opravila?",
"remove_all_tasks_title": "Si prepričan, da želiš odstraniti vsa opravila?",
"delete_files_option": "Izbriši tudi datoteke",
"delete_files_option_hint": "Trajno odstrani izvirne datoteke iz pomnilnika",
"remove_selected_tasks_confirm": "Odstrani {count} opravil?",
"remove_all_tasks_confirm": "Odstrani vsa opravila?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Är du säker på att du vill ta bort valda uppgifter?",
"remove_all_tasks_title": "Är du säker på att du vill ta bort alla uppgifter?",
"delete_files_option": "Radera även filer",
"delete_files_option_hint": "Ta bort nedladdade filer från enheten",
"remove_selected_tasks_confirm": "Ta bort {count} uppgifter?",
"remove_all_tasks_confirm": "Ta bort alla uppgifter?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Seçili görevleri kaldırmak istediğinizden emin misiniz?",
"remove_all_tasks_title": "Tüm görevleri kaldırmak istediğinizden emin misiniz?",
"delete_files_option": "Dosyaları da sil",
"delete_files_option_hint": "Depolamadan özgün dosyaları kalıcı olarak kaldır",
"remove_selected_tasks_confirm": "{count} görevi kaldır?",
"remove_all_tasks_confirm": "Tüm görevleri kaldır?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Ви впевнені, що хочете видалити вибрані завдання?",
"remove_all_tasks_title": "Ви впевнені, що хочете видалити всі завдання?",
"delete_files_option": "Також видалити файли",
"delete_files_option_hint": "Також видалити завантажені файли з пристрою",
"remove_selected_tasks_confirm": "Видалити {count} завдань?",
"remove_all_tasks_confirm": "Видалити всі завдання?"
},

View File

@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "您确定要删除选定的任务吗?",
"remove_all_tasks_title": "您确定要删除所有任务吗?",
"delete_files_option": "同时删除文件",
"delete_files_option_hint": "从存储中永久删除原始文件",
"remove_selected_tasks_confirm": "删除 {count} 个任务?",
"remove_all_tasks_confirm": "删除所有任务?"
},

View File

@@ -0,0 +1,62 @@
package me.eternal.purrfectsnap.common.util.ktx;
import com.highcapable.kavaref.KavaRef;
import com.highcapable.kavaref.condition.FieldCondition;
import com.highcapable.kavaref.resolver.FieldResolver;
import kotlin.Unit;
import kotlin.jvm.functions.Function1;
import java.util.ArrayList;
import java.util.List;
public final class KavaRefFieldBridge {
private KavaRefFieldBridge() {}
private static FieldResolver<Object> resolveField(Object instance, String fieldName) {
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
FieldResolver<Object> resolver = scope.firstFieldOrNull(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
condition.name(fieldName);
return Unit.INSTANCE;
}
});
if (resolver != null) return resolver.of(instance);
current = current.getSuperclass();
}
return null;
}
public static Object getField(Object instance, String fieldName) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
return resolver.get();
}
public static void setField(Object instance, String fieldName, Object value) throws NoSuchFieldException {
FieldResolver<Object> resolver = resolveField(instance, fieldName);
if (resolver == null) throw new NoSuchFieldException(instance.getClass().getName() + "#" + fieldName);
resolver.set(value);
}
public static String[] getAllFieldNames(Object instance) {
List<String> result = new ArrayList<>();
Class<?> current = instance.getClass();
while (current != null && current != Object.class) {
@SuppressWarnings("unchecked")
KavaRef.MemberScope<Object> scope = (KavaRef.MemberScope<Object>) KavaRef.resolveClass((Class<Object>) current);
List<FieldResolver<Object>> fields = scope.field(new Function1<FieldCondition<Object>, Unit>() {
@Override
public Unit invoke(FieldCondition<Object> condition) {
return Unit.INSTANCE;
}
});
for (FieldResolver<Object> field : fields) result.add(field.getSelf().getName());
current = current.getSuperclass();
}
return result.toArray(new String[0]);
}
}

View File

@@ -54,6 +54,9 @@ class MappingsWrapper(
if (!exists()) {
throw Exception("Mappings file does not exist")
}
mappers.values.forEach { mapper ->
mapper.classLoader = context.classLoader
}
val mappingsObject = JsonParser.parseString(readBytes().toString(Charsets.UTF_8)).asJsonObject.also {
mappingUniqueHash = it["unique_hash"].asLong
}
@@ -61,7 +64,6 @@ class MappingsWrapper(
mappingsObject.entrySet().forEach { (key, value) ->
mappers.values.firstOrNull { it.mapperName == key }?.let { mapper ->
mapper.readFromJson(value.asJsonObject)
mapper.classLoader = context.classLoader
}
}
isMappingsLoaded = true
@@ -74,6 +76,9 @@ class MappingsWrapper(
fileHandleManager.value.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.NATIVE_SIG_CACHE.key).delete()
val classMapper = ClassMapper(*mappers.values.toTypedArray())
mappers.values.forEach { mapper ->
mapper.classLoader = context.classLoader
}
runCatching {
classMapper.loadApk(getSnapchatPackageInfo()?.applicationInfo?.sourceDir ?: throw Exception("Failed to get APK"))
@@ -88,6 +93,8 @@ class MappingsWrapper(
writeBytes(result.toString().toByteArray())
}
loadCached()
return classMapper.getWarns()
}
@@ -99,4 +106,4 @@ class MappingsWrapper(
AbstractLogger.directError("Mapper ${type.simpleName} is not registered", Throwable())
}
}
}
}

View File

@@ -11,6 +11,10 @@ data class PropertyPair<T>(
val name get() = key.name
}
@Suppress("UNCHECKED_CAST")
fun Pair<PropertyKey<*>, PropertyValue<*>>.toPropertyPair(): PropertyPair<Any> =
PropertyPair(first as PropertyKey<Any>, second as PropertyValue<Any>)
enum class FeatureNotice(
val key: String
) {

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