Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c5d60b7b | ||
|
|
21cd306132 | ||
|
|
0d42aed0ff | ||
|
|
f8fdd1893f | ||
|
|
a8e2148b26 | ||
|
|
1e9ad8eb2b | ||
|
|
182e7eefeb | ||
|
|
070a8ffaf7 | ||
|
|
95f98221e3 | ||
|
|
dbbc52b67f | ||
|
|
b7c8042a93 | ||
|
|
141b5e16a0 | ||
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 | ||
|
|
7a8ad81d48 | ||
|
|
7db60d6eb1 | ||
|
|
565a8b8287 | ||
|
|
1c612ebc8e | ||
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 | ||
|
|
58c4be44f2 | ||
|
|
3d83c3116c | ||
|
|
2b00898355 | ||
|
|
975a9a101f | ||
|
|
793659c63b | ||
|
|
5bdc1d7f59 | ||
|
|
fc66453167 | ||
|
|
84a9d01d6a | ||
|
|
a4b948bba3 | ||
|
|
8c720992de | ||
|
|
d1fc97cee8 | ||
|
|
9df49f1bff | ||
|
|
652062769e | ||
|
|
f1eb833655 | ||
|
|
a27753d8ae | ||
|
|
da8a261202 | ||
|
|
532ebfe0a7 | ||
|
|
bf7f371022 | ||
|
|
15c9a3fd5d | ||
|
|
bb0ab20a5b | ||
|
|
9a507684ee | ||
|
|
1fbf82b4fb | ||
|
|
53696c26f4 | ||
|
|
381b190535 | ||
|
|
50199b052f | ||
|
|
2135d66124 | ||
|
|
6948d86efc | ||
|
|
481e5840f0 | ||
|
|
216bb71fef | ||
|
|
181d19424f | ||
|
|
a4d2513138 | ||
|
|
9c0f14e797 | ||
|
|
800b40aaae | ||
|
|
9577dadd38 | ||
|
|
d4a084f896 | ||
|
|
28df958197 | ||
|
|
9b0f336f91 | ||
|
|
c52f60a29b | ||
|
|
a327786d9e | ||
|
|
2b0c6cae36 | ||
|
|
05a383c823 | ||
|
|
13cd3656fd | ||
|
|
07a8d3041e | ||
|
|
5ece2ec408 | ||
|
|
7f9b911f0b | ||
|
|
b1b6a4ae6f | ||
|
|
eeee0c2e72 | ||
|
|
07d4a4f833 | ||
|
|
4adcff36e3 | ||
|
|
26077a8c87 | ||
|
|
28982aa655 | ||
|
|
89748cd261 | ||
|
|
97797127bd | ||
|
|
23d8ff0370 | ||
|
|
d986440f9f | ||
|
|
fc3b1bcde1 | ||
|
|
0289f96019 | ||
|
|
ec9ecac15c | ||
|
|
5efccc9e78 | ||
|
|
ae53cc1f72 | ||
|
|
5b170ebae6 | ||
|
|
87ca0eaa82 | ||
|
|
5496dc7eb5 | ||
|
|
183164fd52 | ||
|
|
4987577001 | ||
|
|
c7a879fa62 | ||
|
|
d6e74cb207 | ||
|
|
1ccdaee879 | ||
|
|
956bdda6c0 | ||
|
|
1b1f68c6cf | ||
|
|
b6f9c10b21 | ||
|
|
fb3f1f55c7 | ||
|
|
f8e5d7370a | ||
|
|
7623ec30c9 | ||
|
|
7be8e33cc6 | ||
|
|
bc252ea9a3 | ||
|
|
4aba9c815f | ||
|
|
05c71c7ef4 | ||
|
|
57ebadbc51 | ||
|
|
bdf3808526 | ||
|
|
e8d1f62ff1 | ||
|
|
b64faf31ae | ||
|
|
ed86916ed7 | ||
|
|
5935afb6e1 | ||
|
|
1ac1542384 | ||
|
|
8eea50e745 | ||
|
|
795bc1f96a | ||
|
|
0ab60d8b20 | ||
|
|
627abb3927 | ||
|
|
f32a4c4a23 | ||
|
|
4753d91315 | ||
|
|
f1dc4429d8 | ||
|
|
e6135584d4 | ||
|
|
ea82a5d373 | ||
|
|
36aa0c52a2 | ||
|
|
e3008e0c9c | ||
|
|
c50327bfe9 | ||
|
|
a97449605a | ||
|
|
e53a7db9ed | ||
|
|
c16f1f130d | ||
|
|
6eec014493 | ||
|
|
a21b938be0 | ||
|
|
3744c5b422 | ||
|
|
4692987baf | ||
|
|
869bf3f3b4 | ||
|
|
ce67a4f2a7 | ||
|
|
6c1072171d | ||
|
|
6559d4dcd2 | ||
|
|
a1aef48b0c | ||
|
|
0779a496eb | ||
|
|
6227a078e4 | ||
|
|
dac42ea042 | ||
|
|
0df57f7724 | ||
|
|
8d6dcdb227 | ||
|
|
46e9b7e7f5 | ||
|
|
d485718245 | ||
|
|
a4de81aaf6 | ||
|
|
a8e0c53be0 | ||
|
|
a0728fa951 | ||
|
|
5c7b342195 | ||
|
|
67dcc16923 | ||
|
|
214ec4ffdd | ||
|
|
a821cdf85e | ||
|
|
1953397adf | ||
|
|
c20c157c2b | ||
|
|
60c1bf8569 | ||
|
|
08610ee398 | ||
|
|
1e6e9ada62 | ||
|
|
528adc6255 | ||
|
|
c4246e853c | ||
|
|
ff398ccc9a | ||
|
|
3ce4617deb | ||
|
|
228d5e0b23 | ||
|
|
d65938ab69 | ||
|
|
e0ea732855 | ||
|
|
58928f70e9 | ||
|
|
0d9267c13d | ||
|
|
9e41e8154a | ||
|
|
c5f164ccc8 | ||
|
|
c9052cba26 | ||
|
|
00ca6861f4 | ||
|
|
458e1dde0d | ||
|
|
a3441e02a8 | ||
|
|
79f9715001 | ||
|
|
ca35f68e88 | ||
|
|
79d1f9098a | ||
|
|
f551daf3a4 | ||
|
|
01594b24b5 | ||
|
|
a92f577aec | ||
|
|
86370bd9d3 | ||
|
|
296769af49 | ||
|
|
1401aa90cc | ||
|
|
6a752b68d9 | ||
|
|
fe6785f7f1 | ||
|
|
06046340c6 | ||
|
|
403c9df173 | ||
|
|
67b493e6bb | ||
|
|
cd924b1b0d | ||
|
|
5d2a3dedb7 | ||
|
|
7fe49f2fe4 | ||
|
|
327efafbe5 | ||
|
|
de4d8dac39 | ||
|
|
d8d82cd098 | ||
|
|
222b4885ba | ||
|
|
ef4f4c78cb | ||
|
|
ec05a12e6c | ||
|
|
2c5f54368c | ||
|
|
18f280cfd7 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -45,7 +45,7 @@ body:
|
||||
attributes:
|
||||
label: "SnapEnhance Version"
|
||||
description: "On which SnapEnhance version is this happening?"
|
||||
placeholder: "ex. 1.2.5"
|
||||
placeholder: "ex. 1.3.6"
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
|
||||
3
.gitignore
vendored
@@ -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
|
||||
313
README.md
@@ -1,10 +1,14 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap" width="180"/>
|
||||
<div align="center">
|
||||
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/banner.jpg" alt="PurrfectSnap" width="100%"/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
# PurrfectSnap
|
||||
|
||||
### An Xposed module meant to redefine your Snapchat experience! Works on both non rooted and rooted devices!
|
||||
### An Xposed module meant to redefine your Snapchat experience! Works on both non-rooted and rooted devices!
|
||||
|
||||
<br>
|
||||
|
||||
@@ -12,7 +16,7 @@
|
||||
[](https://github.com/particle-box/PurrfectSnap/releases)
|
||||
[](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
|
||||
|
||||
[Installation](#installation) • [Features](#features) • [Changelog](#whats-new) • [Community](#community)
|
||||
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -22,7 +26,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
PurrfectSnap is built on the foundation of SnapEnhance, pushing boundaries with innovative features and a refined user experience. This isn't just another fork, it's a complete reimagining of what's possible.
|
||||
PurrfectSnap is built on the foundation of SnapEnhance, pushing boundaries with innovative features and a refined user experience. This isn't just another fork; it's a complete reimagining of what's possible.
|
||||
|
||||
We're committed to active development, bringing you powerful tools that actually matter. Every feature is designed with real users in mind, not just for the sake of adding to a feature list.
|
||||
|
||||
@@ -32,47 +36,14 @@ We're committed to active development, bringing you powerful tools that actually
|
||||
|
||||
This project exists because we believe in continuous innovation. We're grateful to the original SnapEnhance team for their groundbreaking work, and we're building on that legacy by exploring new possibilities and listening to what the community actually wants.
|
||||
|
||||
No aggressive donation requests. No minimal changes disguised as "major updates." Just genuine development driven by passion for creating something exceptional.
|
||||
|
||||
<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>
|
||||
|
||||
---
|
||||
No aggressive donation requests. No minimal changes disguised as “major updates.” Just genuine development driven by a passion for creating something exceptional.
|
||||
|
||||
<br>
|
||||
|
||||
## Features
|
||||
|
||||
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
@@ -125,7 +96,7 @@ Privacy-focused messaging features with advanced control over your conversations
|
||||
- Screenshot bypass
|
||||
- Anonymous story viewing
|
||||
- Hide typing notifications
|
||||
- Hide bitmoji presence
|
||||
- Hide Bitmoji presence
|
||||
- Prevent story rewatch indicators
|
||||
|
||||
**Enhanced Features**
|
||||
@@ -240,149 +211,8 @@ Extensibility through custom scripts.
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
## What's New
|
||||
|
||||
Here's what makes PurrfectSnap different from upstream SnapEnhance.
|
||||
|
||||
<br>
|
||||
|
||||
### Aurora Design System
|
||||
|
||||
A complete visual language rebuilt from the ground up. Not just a theme, an entire design philosophy.
|
||||
|
||||
The interface now flows with purpose. Subtle animations guide your interactions. Every screen has been reconsidered, every transition refined. The result is an experience that feels premium without being ostentatious.
|
||||
|
||||
<br>
|
||||
|
||||
### PurrAura
|
||||
|
||||
The ban problem? Solved.
|
||||
|
||||
No more sacrificing features to stay safe. Add friends, block users, attach music; everything works as it should. No workarounds, no compromises, no disadvantages.
|
||||
|
||||
<br>
|
||||
|
||||
### Auto Patcher
|
||||
|
||||
Installation was complicated. Now it isn't.
|
||||
|
||||
We've eliminated 90% of the installation friction. No Shizuku. No LSPatch. Just a straightforward process that respects your time.
|
||||
|
||||
<br>
|
||||
|
||||
### Intelligence Layer
|
||||
|
||||
**Auto Reply**
|
||||
Let AI handle routine responses while you focus on what matters. Configure automatic replies for chats, stories, and half-swipes. Essential for creators managing volume. Requires an API key from Google AI Studio, they offer free tier access.
|
||||
|
||||
**Scheduled Snaps**
|
||||
Time-shifted communication. Set a snap to send at midnight for a birthday. Queue content for optimal timing. Your schedule, automated.
|
||||
|
||||
**Message Translator**
|
||||
Language barriers, eliminated. Incoming messages automatically translate to your preferred language. Set it once, communicate globally.
|
||||
|
||||
<br>
|
||||
|
||||
### Privacy Enhancements
|
||||
|
||||
**Granular Controls**
|
||||
Whitelist and blacklist modes for typing indicators and message logging. Choose exactly who sees what, friend by friend.
|
||||
|
||||
**Auto Delete Messages**
|
||||
Set expiration times for your messages. They disappear on your schedule, not Snapchat's.
|
||||
|
||||
**Message Logger Viewer**
|
||||
Review exported logs directly in-app. No external tools required.
|
||||
|
||||
<br>
|
||||
|
||||
### Data Persistence
|
||||
|
||||
**Friend Notes Backup**
|
||||
Your notes are valuable. Now they're protected. Export and restore with ease. Never lose context again.
|
||||
|
||||
<br>
|
||||
|
||||
### Device Management
|
||||
|
||||
**ID Spoofing**
|
||||
Device bans happen. Sometimes unjustly. We offer a solution for legitimate cases, approval required with proof of wrongful ban. This feature is restricted to prevent abuse.
|
||||
|
||||
**Model Spoofing**
|
||||
Identify as a high-end device. Potential performance improvements, enhanced camera processing, better feature access.
|
||||
|
||||
**Network Spoofing**
|
||||
Spoof your network status to Wi-Fi always to prevent several restrictions on mobile data.
|
||||
|
||||
<br>
|
||||
|
||||
### Restored Functionality
|
||||
|
||||
Features that disappeared? They're back.
|
||||
|
||||
Bulk messaging works again. Friend list management is fully operational. These aren't new features, they're restored capabilities that should never have been lost.
|
||||
|
||||
<br>
|
||||
|
||||
### Discovery
|
||||
|
||||
**Scripts Catalog**
|
||||
Browse and install scripts without leaving the app. No more hunting for import links. One-tap installation. Always current.
|
||||
|
||||
**Friend Tracker Catalog**
|
||||
Browse and install tracking rules without leaving the app. One-tap installation. Always current.
|
||||
|
||||
**Friend Tracker Import/Export**
|
||||
Backup and restore your Friend Tracker configurations with ease.
|
||||
|
||||
<br>
|
||||
|
||||
### Advanced Tracking
|
||||
|
||||
**I Can See You Rule**
|
||||
Track the exact time and duration when a friend enters your chat. Precision visibility tracking for Friend Tracker.
|
||||
|
||||
**Auto Open Snaps Enhancement**
|
||||
More granular control with queue size configuration and customizable delays.
|
||||
|
||||
<br>
|
||||
|
||||
### Visual Refinements
|
||||
|
||||
**AMOLED Theme**
|
||||
True black. Battery-saving. The most requested feature, delivered.
|
||||
|
||||
**Haptic Feedback**
|
||||
Subtle tactile responses. Every interaction confirmed through touch.
|
||||
|
||||
**Customizable Bottom Bar**
|
||||
Your navigation, your rules. Rearrange tabs, remove what you don't use, set your default view. Complete flexibility.
|
||||
|
||||
**Search History**
|
||||
Recently searched features, instantly accessible. No more hunting through menus.
|
||||
|
||||
<br>
|
||||
|
||||
### Infrastructure
|
||||
|
||||
**In-App Updates**
|
||||
New versions install directly from within the app. No GitHub hunting. No manual downloads. Just seamless updates.
|
||||
|
||||
**Update Notifications**
|
||||
Stay current automatically. Get notified when new releases are available.
|
||||
|
||||
**Config Export/Import Summary**
|
||||
See exactly which features a config will enable before importing or exporting. Know what you're getting.
|
||||
|
||||
<br>
|
||||
|
||||
### Quality
|
||||
|
||||
Hundreds of refinements you'll feel but might not see. Smoother animations. Faster response times. Eliminated edge cases. The kind of polish that separates good from exceptional.
|
||||
> ⚠️Some features are intended for educational, recovery, or accessibility purposes. Misuse may violate Snapchat’s terms of service.
|
||||
> **We are not affiliated with Snap Inc. and are not responsible for any violations by the user. Our sole purpose is for education purposes only!**
|
||||
|
||||
<br>
|
||||
|
||||
@@ -392,9 +222,8 @@ Hundreds of refinements you'll feel but might not see. Smoother animations. Fast
|
||||
|
||||
## Installation
|
||||
|
||||
The guide is no longer needed!
|
||||
For Non rooted users: Just Download & Install PurrfectSnap from [here](https://github.com/particle-box/PurrfectSnap/releases), and it'll do everything automatically for you!
|
||||
For rooted users: Use Snapchat v13.64.0.52 only! Install PurrfectSnap from here: [Link](https://github.com/particle-box/PurrfectSnap/releases)
|
||||
The guide is no longer needed!
|
||||
Just download and install PurrfectSnap from [here](https://github.com/particle-box/PurrfectSnap/releases). The app automatically detects your device type and applies the appropriate setup.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -406,7 +235,7 @@ For rooted users: Use Snapchat v13.64.0.52 only! Install PurrfectSnap from here:
|
||||
|
||||
Questions? Ideas? Found a bug? Our community is active and responsive.
|
||||
|
||||
**[Telegram Channel](https://t.me/purrfectsnap_official)** — Announcements, discussions, and support
|
||||
**[Telegram Channel](https://t.me/purrfectsnap_official)** : Announcements, discussions, and support
|
||||
**For the discussions group link, refer to the description of the channel!**
|
||||
|
||||
<br>
|
||||
@@ -415,28 +244,103 @@ 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, or design—quality contributions are always appreciated.
|
||||
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
|
||||
|
||||
PurrfectSnap is built with exceptional open source tools:
|
||||
PurrfectSnap is built with exceptional open source tools. We do not collect any user information. However, please be aware that third-party libraries may collect data as described in their respective privacy policies.
|
||||
|
||||
**Core Dependencies**
|
||||
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
|
||||
- [libxposed](https://github.com/libxposed/api) — Framework integration
|
||||
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
|
||||
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
|
||||
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
|
||||
- [O‑MVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation
|
||||
- [hiddenapibypass](https://github.com/LSPosed/HiddenApiBypass) — Hidden API access
|
||||
- [dexlib2 (smali)](https://github.com/JesusFreke/smali) — Dex parsing
|
||||
- [Bouncy Castle](https://www.bouncycastle.org/) — Cryptography
|
||||
- [apksig](https://android.googlesource.com/platform/tools/apksig/) — APK signing tools
|
||||
- [Rust Android Gradle Plugin](https://github.com/mozilla/rust-android-gradle) — Native build tooling
|
||||
- [Android Gradle Plugin](https://developer.android.com/build) — Build system
|
||||
- [Kotlin](https://kotlinlang.org/) — Language
|
||||
- [Compose Compiler](https://developer.android.com/jetpack/compose/compiler) — Compose compiler
|
||||
|
||||
**Media Processing**
|
||||
- [ffmpeg-kit-full-gpl](https://github.com/arthenica/ffmpeg-kit) — Media manipulation
|
||||
- [coil](https://github.com/coil-kt/coil) — Image loading
|
||||
- [smart-exception-java](https://github.com/arthenica/ffmpeg-kit) — ffmpeg-kit support lib
|
||||
|
||||
**Scripting Engine**
|
||||
- [rhino](https://github.com/mozilla/rhino) — JavaScript runtime
|
||||
@@ -446,6 +350,21 @@ PurrfectSnap is built with exceptional open source tools:
|
||||
- [osmdroid](https://github.com/osmdroid/osmdroid) — Map functionality
|
||||
- [libsu](https://github.com/topjohnwu/libsu) — Root operations
|
||||
- [colorpicker-compose](https://github.com/skydoves/colorpicker-compose) — Color selection
|
||||
- [OkHttp](https://github.com/square/okhttp) — Networking
|
||||
- [Gson](https://github.com/google/gson) — JSON parsing
|
||||
- [jsoup](https://github.com/jhy/jsoup) — HTML parsing
|
||||
- [Fetch](https://github.com/tonyofrancis/Fetch) — Download manager
|
||||
- [WorkManager](https://developer.android.com/jetpack/androidx/releases/work) — Background tasks
|
||||
- [Accompanist](https://github.com/google/accompanist) — Compose utilities
|
||||
- [Guava](https://github.com/google/guava) — Core utilities
|
||||
- [Kotlin Coroutines](https://github.com/Kotlin/kotlinx.coroutines) — Async
|
||||
- [AndroidX DocumentFile](https://developer.android.com/reference/androidx/documentfile/provider/DocumentFile) — File access
|
||||
- [AndroidX RecyclerView](https://developer.android.com/jetpack/androidx/releases/recyclerview) — Lists
|
||||
- [AndroidX Navigation Compose](https://developer.android.com/jetpack/androidx/releases/navigation) — Navigation
|
||||
- [Jetpack Compose / Material3](https://developer.android.com/jetpack/compose) — UI toolkit
|
||||
- [AndroidX Activity KTX](https://developer.android.com/jetpack/androidx/releases/activity) — Activity helpers
|
||||
- [Compose BOM](https://developer.android.com/jetpack/compose/bom) — Compose version alignment
|
||||
- [Material Icons (Compose)](https://developer.android.com/jetpack/compose/designsystems/material#icons) — Icons
|
||||
|
||||
<br>
|
||||
|
||||
@@ -455,8 +374,8 @@ PurrfectSnap is built with exceptional open source tools:
|
||||
|
||||
<br>
|
||||
|
||||
Built with care, maintained with passion
|
||||
|
||||
Built with care, maintained with passion ❤️
|
||||
Open source under GPL-3.0, Apache-2.0
|
||||
<br>
|
||||
<br>
|
||||
|
||||
|
||||
1
announcements.txt
Normal file
@@ -0,0 +1 @@
|
||||
- Test
|
||||
@@ -100,6 +100,10 @@ fun computeKeystoreCertSha256(storeFile: File, storePass: String, keyAlias: Stri
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun gradleOrEnv(name: String, providers: org.gradle.api.provider.ProviderFactory): String? {
|
||||
return providers.gradleProperty(name).orNull ?: System.getenv(name)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = rootProject.ext["applicationId"].toString()
|
||||
compileSdk = 36
|
||||
@@ -113,18 +117,18 @@ android {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
storePassword = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
|
||||
keyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
|
||||
keyPassword = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
|
||||
storePassword = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
keyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
keyPassword = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
val autoCertSha = providers.provider {
|
||||
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPass = providers.gradleProperty("PS_RELEASE_KEY_PASSWORD").orNull
|
||||
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
val releaseKeyPass = gradleOrEnv("PS_RELEASE_KEY_PASSWORD", providers)
|
||||
if (!releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
computeKeystoreCertSha256(
|
||||
releaseStore,
|
||||
@@ -147,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()}\"")
|
||||
@@ -158,8 +162,8 @@ android {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles += file("proguard-rules.pro")
|
||||
val releaseStore = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
|
||||
val releaseStorePass = providers.gradleProperty("PS_RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("PS_RELEASE_KEY_ALIAS").orNull
|
||||
val releaseStorePass = gradleOrEnv("PS_RELEASE_STORE_PASSWORD", providers)
|
||||
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
|
||||
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
@@ -263,6 +267,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":core"))
|
||||
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":native"))
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
4
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
||||
|
||||
-dontwarn de.robv.android.xposed.**
|
||||
-dontwarn org.mozilla.javascript.**
|
||||
-dontwarn android.app.AndroidAppHelper
|
||||
-dontwarn java.lang.reflect.AnnotatedType
|
||||
|
||||
-keep class com.tonyodev.fetch2.** { *; }
|
||||
-keep class com.tonyodev.fetch2core.** { *; }
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.7
|
||||
0.8
|
||||
@@ -21,6 +21,12 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import me.eternal.purrfectsnap.bridge.BridgeService
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.Constants
|
||||
@@ -45,12 +51,14 @@ import me.eternal.purrfectsnap.ui.manager.data.SnapchatAppInfo
|
||||
import me.eternal.purrfectsnap.ui.overlay.RemoteOverlay
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.setup.SetupActivity
|
||||
import me.eternal.purrfectsnap.task.AnnouncementCheckWorker
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import com.tonyodev.fetch2.Fetch
|
||||
import com.tonyodev.fetch2.FetchConfiguration
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class RemoteSideContext(
|
||||
@@ -123,6 +131,7 @@ class RemoteSideContext(
|
||||
log.init()
|
||||
log.verbose("Loading RemoteSideContext")
|
||||
config.load()
|
||||
ensureAutoUpdateCheckOnUpgrade()
|
||||
launch {
|
||||
mappings.apply {
|
||||
init(androidContext)
|
||||
@@ -132,6 +141,7 @@ class RemoteSideContext(
|
||||
userLocale = config.locale
|
||||
load()
|
||||
}
|
||||
scheduleAnnouncementCheck()
|
||||
database.init()
|
||||
streaksReminder.init()
|
||||
scriptManager.init()
|
||||
@@ -258,10 +268,48 @@ class RemoteSideContext(
|
||||
Constants.SNAPCHAT_PACKAGE_NAME
|
||||
)
|
||||
if (intent == null) {
|
||||
shortToast("Can't execute action: Snapchat is not installed")
|
||||
shortToast(translation["toast_snapchat_not_installed"])
|
||||
return
|
||||
}
|
||||
intent.putExtra(EnumAction.ACTION_PARAMETER, action.key)
|
||||
androidContext.startActivity(intent)
|
||||
}
|
||||
|
||||
private fun scheduleAnnouncementCheck() {
|
||||
val workManager = WorkManager.getInstance(androidContext)
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val inputData = Data.Builder()
|
||||
.putString("announcements_url", "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt")
|
||||
.putString("channel_name", "Announcements")
|
||||
.putString("channel_description", "Notifications for PurrfectSnap announcements")
|
||||
.putString("notification_title", "New announcement available")
|
||||
.putString("notification_text", "Tap to open and read.")
|
||||
.build()
|
||||
val workRequest = PeriodicWorkRequestBuilder<AnnouncementCheckWorker>(1, TimeUnit.DAYS)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
"purrfectsnap_announcement_check",
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureAutoUpdateCheckOnUpgrade() {
|
||||
val currentVersion = BuildConfig.VERSION_CODE.toLong()
|
||||
val lastVersion = sharedPreferences.getLong("last_build_version_code", -1L)
|
||||
val reenabledOnce = sharedPreferences.getBoolean("auto_update_reenabled_once", false)
|
||||
if (lastVersion == currentVersion) return
|
||||
if (!reenabledOnce) {
|
||||
config.root.global.updateSettings.autoUpdateCheck.set(true)
|
||||
config.writeConfig()
|
||||
sharedPreferences.edit()
|
||||
.putBoolean("auto_update_reenabled_once", true)
|
||||
.apply()
|
||||
}
|
||||
sharedPreferences.edit().putLong("last_build_version_code", currentVersion).apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ class BiometricPromptActivity: ComponentActivity() {
|
||||
setContent {}
|
||||
}
|
||||
|
||||
@Deprecated("Overrides deprecated API onActivityResult")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +159,7 @@ class BridgeService : Service() {
|
||||
val pendingTask = remoteSideContext.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Media conversion",
|
||||
title = remoteSideContext.translation["task_media_conversion_title"],
|
||||
author = null,
|
||||
hash = taskId
|
||||
)
|
||||
@@ -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? {
|
||||
|
||||
@@ -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,155 @@ class DownloadProcessor (
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
|
||||
stream1.use { s1 ->
|
||||
stream2.use { s2 ->
|
||||
val buffer1 = ByteArray(1024 * 1024)
|
||||
val buffer2 = ByteArray(1024 * 1024)
|
||||
while (true) {
|
||||
val read1 = s1.read(buffer1)
|
||||
val read2 = s2.read(buffer2)
|
||||
if (read1 != read2) return false
|
||||
if (read1 == -1) return true
|
||||
for (i in 0 until read1) {
|
||||
if (buffer1[i] != buffer2[i]) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
|
||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?"
|
||||
val selectionArgs = arrayOf(fileName, relativePath)
|
||||
return remoteSideContext.androidContext.contentResolver.query(
|
||||
collection,
|
||||
projection,
|
||||
selection,
|
||||
selectionArgs,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||
ContentUris.withAppendedId(collection, id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentMatches(uri: Uri, inputFile: File): Boolean {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false
|
||||
return streamsMatch(existingInputStream, inputFile.inputStream())
|
||||
}
|
||||
|
||||
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
|
||||
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
|
||||
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
|
||||
|
||||
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
|
||||
rawName.substringBeforeLast(".")
|
||||
} else {
|
||||
rawName
|
||||
}
|
||||
|
||||
return "${sanitizeFileName(baseName)}.$targetExtension"
|
||||
}
|
||||
|
||||
private fun sanitizeFileName(name: String): String {
|
||||
return name
|
||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
||||
.replace(Regex("\\p{Cntrl}"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.trim('.')
|
||||
.ifBlank { "media" }
|
||||
}
|
||||
|
||||
private fun sanitizeRelativePath(path: String): String {
|
||||
return path.trimEnd('/').split("/")
|
||||
.mapNotNull { segment ->
|
||||
segment.trim()
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let(::sanitizeFileName)
|
||||
}
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
private fun appendNameSuffix(fileName: String, index: Int): String {
|
||||
val extension = fileName.substringAfterLast('.', "")
|
||||
val baseName = fileName.substringBeforeLast(".", fileName)
|
||||
return if (extension.isBlank()) {
|
||||
"$baseName ($index)"
|
||||
} else {
|
||||
"$baseName ($index).$extension"
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToConfiguredFolder(
|
||||
configuredFolder: String,
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
pendingTask: PendingTask,
|
||||
): GallerySaveResult {
|
||||
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
|
||||
?: throw Exception("Failed to open output folder")
|
||||
|
||||
val outputFileFolder = metadata.outputPath.let {
|
||||
if (it.contains("/")) {
|
||||
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
|
||||
folder.findFile(name)
|
||||
?: folder.createDirectory(name)
|
||||
?: throw Exception("Failed to create output directory $name")
|
||||
}
|
||||
} else {
|
||||
outputFolder
|
||||
}
|
||||
}
|
||||
|
||||
var finalFileName = fileName
|
||||
var collisionCount = 0
|
||||
|
||||
while (true) {
|
||||
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
|
||||
|
||||
if (existingFile.length() == inputFile.length()) {
|
||||
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
|
||||
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
|
||||
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
|
||||
}
|
||||
}
|
||||
|
||||
collisionCount++
|
||||
finalFileName = appendNameSuffix(fileName, collisionCount)
|
||||
}
|
||||
|
||||
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
|
||||
?: throw Exception("Failed to create output file $finalFileName")
|
||||
|
||||
pendingTask.updateProgress("Saving media to gallery")
|
||||
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
|
||||
?: throw Exception("Failed to open output stream for $finalFileName")
|
||||
|
||||
outputStream.use { currentOutputStream ->
|
||||
inputFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(currentOutputStream)
|
||||
}
|
||||
}
|
||||
|
||||
return GallerySaveResult(outputFile.uri)
|
||||
}
|
||||
|
||||
private fun saveToSystemDefault(
|
||||
fileName: String,
|
||||
fileType: FileType,
|
||||
inputFile: File,
|
||||
metadata: DownloadMetadata,
|
||||
): Uri? {
|
||||
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
.trim('/')
|
||||
): GallerySaveResult? {
|
||||
val subPath = sanitizeRelativePath(
|
||||
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
|
||||
.replace("\\", "/")
|
||||
)
|
||||
val baseRelative = when {
|
||||
fileType.isImage -> Environment.DIRECTORY_PICTURES
|
||||
fileType.isVideo -> Environment.DIRECTORY_MOVIES
|
||||
@@ -222,40 +327,65 @@ class DownloadProcessor (
|
||||
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
|
||||
.joinToString("/") + "/"
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val collection = when {
|
||||
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
}
|
||||
val resolver = remoteSideContext.androidContext.contentResolver
|
||||
val uri = resolver.insert(collection, values) ?: return null
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: return null
|
||||
uri
|
||||
|
||||
for (attempt in 0..100) {
|
||||
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
|
||||
|
||||
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
|
||||
if (contentMatches(existingUri, inputFile)) {
|
||||
return GallerySaveResult(existingUri, alreadyDownloaded = true)
|
||||
}
|
||||
return@let
|
||||
} ?: run {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
|
||||
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run
|
||||
|
||||
runCatching {
|
||||
resolver.openOutputStream(uri)?.use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
} ?: throw IllegalStateException("Failed to open output stream")
|
||||
|
||||
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) }
|
||||
return GallerySaveResult(uri)
|
||||
}.onFailure {
|
||||
runCatching { resolver.delete(uri, null, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("Failed to allocate unique filename")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
|
||||
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
|
||||
destDir.mkdirs()
|
||||
val destFile = File(destDir, fileName)
|
||||
FileOutputStream(destFile).use { out ->
|
||||
inputFile.inputStream().use { it.copyTo(out) }
|
||||
|
||||
var destFile = File(destDir, fileName)
|
||||
var suffix = 1
|
||||
while (destFile.exists()) {
|
||||
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
|
||||
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
|
||||
}
|
||||
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
|
||||
}
|
||||
|
||||
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
|
||||
runCatching {
|
||||
remoteSideContext.androidContext.sendBroadcast(
|
||||
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
|
||||
data = Uri.fromFile(destFile)
|
||||
}
|
||||
)
|
||||
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
|
||||
}
|
||||
Uri.fromFile(destFile)
|
||||
return GallerySaveResult(Uri.fromFile(destFile))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +393,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 +406,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 +482,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 +509,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 +534,7 @@ class DownloadProcessor (
|
||||
|
||||
dashPlaylistFile.delete()
|
||||
outputFile.delete()
|
||||
media.file.delete()
|
||||
media.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +554,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 +569,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]\""
|
||||
|
||||
@@ -255,20 +261,6 @@ class FFMpegProcessor(
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ import me.eternal.purrfectsnap.download.FFMpegProcessor
|
||||
import me.eternal.purrfectsnap.task.PendingTaskListener
|
||||
import me.eternal.purrfectsnap.task.Task
|
||||
import me.eternal.purrfectsnap.task.TaskType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.absoluteValue
|
||||
@@ -43,7 +46,7 @@ class CallDownloadSessionImpl(
|
||||
val job: Job
|
||||
val writePfd: ParcelFileDescriptor
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
|
||||
if (exists()) delete()
|
||||
}
|
||||
|
||||
@@ -109,11 +112,15 @@ class CallDownloadSessionImpl(
|
||||
return
|
||||
}
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}_final.mp3")
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
|
||||
val dateString = dateFormat.format(Date(callStartTimestamp))
|
||||
val finalFileName = "Call_${author}_$dateString"
|
||||
|
||||
val outputFile = context.androidContext.cacheDir.resolve("${finalFileName}_final.mp3")
|
||||
val pendingTask = context.taskManager.createPendingTask(
|
||||
Task(
|
||||
type = TaskType.DOWNLOAD,
|
||||
title = "Call Recording $author",
|
||||
title = context.translation.format("task_call_recording_title", "author" to author),
|
||||
author = author,
|
||||
hash = UUID.randomUUID().toString()
|
||||
)
|
||||
@@ -136,24 +143,30 @@ class CallDownloadSessionImpl(
|
||||
}
|
||||
|
||||
val sortedStreams = streams.filter { it.outputFile.exists() }.sortedBy { it.startTimestampMillis }
|
||||
if (sortedStreams.isEmpty()) {
|
||||
pendingTask.fail("No recorded audio data")
|
||||
return@launch
|
||||
}
|
||||
|
||||
FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute(
|
||||
FFMpegProcessor.Request(
|
||||
action = FFMpegProcessor.Action.MERGE_AUDIO_STREAMS,
|
||||
inputs = sortedStreams.map { it.outputFile.absolutePath },
|
||||
output = outputFile,
|
||||
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp) }
|
||||
inputDelayOffsets = sortedStreams.associate { stream -> stream.outputFile.absolutePath to (stream.startTimestampMillis - callStartTimestamp).coerceAtLeast(0L) }
|
||||
)
|
||||
)
|
||||
|
||||
DownloadProcessor(context, object: DownloadCallback.Default() {
|
||||
override fun onSuccess(outputPath: String) {
|
||||
context.log.verbose("Downloaded call $outputPath")
|
||||
context.shortToast(context.translation["features.properties.downloader.properties.call_recorder.properties.call_recording_saved_toast"])
|
||||
}
|
||||
}).saveMediaToGallery(pendingTask, outputFile, DownloadMetadata(
|
||||
mediaIdentifier = UUID.randomUUID().toString(),
|
||||
outputPath = createNewFilePath(
|
||||
context.config.root,
|
||||
UUID.randomUUID().toString().hashCode().absoluteValue.toString(16),
|
||||
finalFileName,
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL,
|
||||
mediaAuthor = author,
|
||||
creationTimestamp = System.currentTimeMillis()
|
||||
@@ -162,6 +175,9 @@ class CallDownloadSessionImpl(
|
||||
downloadSource = MediaDownloadSource.VOICE_CALL.translate(context.translation),
|
||||
iconUrl = null
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
context.log.error("Failed to merge call recording", e)
|
||||
pendingTask.fail("Merge failed: ${e.message}")
|
||||
} finally {
|
||||
streams.forEach { stream ->
|
||||
stream.outputFile.delete()
|
||||
|
||||
@@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.replaceMessagingData(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
executeAsync {
|
||||
database.beginTransaction()
|
||||
try {
|
||||
val friendIds = friends.map { it.userId }.toSet()
|
||||
val groupIds = groups.map { it.conversationId }.toSet()
|
||||
|
||||
getFriends().forEach { friend ->
|
||||
if (friend.userId !in friendIds) {
|
||||
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
|
||||
}
|
||||
}
|
||||
|
||||
getGroups().forEach { group ->
|
||||
if (group.conversationId !in groupIds) {
|
||||
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
|
||||
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
|
||||
}
|
||||
}
|
||||
|
||||
friends.forEach { friend ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
friend.dmConversationId,
|
||||
friend.displayName,
|
||||
friend.mutableUsername,
|
||||
friend.bitmojiId,
|
||||
friend.selfieId
|
||||
)
|
||||
)
|
||||
|
||||
friend.streaks?.takeIf { it.length > 0 }?.also {
|
||||
val streaks = getFriendStreaks(friend.userId)
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
friend.userId,
|
||||
streaks?.notify != false,
|
||||
it.expirationTimestamp,
|
||||
it.length
|
||||
)
|
||||
)
|
||||
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
database.execSQL(
|
||||
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
|
||||
arrayOf<Any?>(
|
||||
group.conversationId,
|
||||
group.name,
|
||||
group.participantsCount
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
database.setTransactionSuccessful()
|
||||
} finally {
|
||||
database.endTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
|
||||
return database.rawQuery(
|
||||
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package me.eternal.purrfectsnap.task
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.ui.manager.MainActivity
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.security.MessageDigest
|
||||
|
||||
class AnnouncementCheckWorker(
|
||||
private val appContext: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return runCatching {
|
||||
val url = inputData.getString("announcements_url") ?: return Result.failure()
|
||||
val body = fetchBody(url)?.trim().orEmpty()
|
||||
if (body.isEmpty()) return Result.success()
|
||||
|
||||
val hash = sha256(body)
|
||||
val prefs = appContext.getSharedPreferences("prefs", 0)
|
||||
val lastHash = prefs.getString("announcements_last_hash", null)
|
||||
if (lastHash == null) {
|
||||
prefs.edit().putString("announcements_last_hash", hash).apply()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (lastHash != hash) {
|
||||
prefs.edit().putString("announcements_last_hash", hash).apply()
|
||||
showAnnouncementNotification()
|
||||
}
|
||||
Result.success()
|
||||
}.getOrElse {
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchBody(url: String): String? {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(url).build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
return response.body?.string()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256(text: String): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { byte -> "%02x".format(byte) }
|
||||
}
|
||||
|
||||
private fun showAnnouncementNotification() {
|
||||
val channelId = "purrfectsnap_announcements"
|
||||
val name = inputData.getString("channel_name") ?: "Announcements"
|
||||
val descriptionText = inputData.getString("channel_description") ?: "Notifications for PurrfectSnap announcements"
|
||||
val title = inputData.getString("notification_title") ?: "New announcement available"
|
||||
val text = inputData.getString("notification_text") ?: "Tap to open and read."
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val intent = Intent(appContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra("route", "home")
|
||||
putExtra("show_announcements", true)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return
|
||||
}
|
||||
}
|
||||
with(NotificationManagerCompat.from(appContext)) {
|
||||
notify(2, builder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ data class Task(
|
||||
val type: TaskType,
|
||||
val title: String,
|
||||
val author: String?,
|
||||
val hash: String
|
||||
val hash: String,
|
||||
val isAutoOpen: Boolean = false
|
||||
) {
|
||||
var changeListener: () -> Unit = {}
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ class RemoteTaskInterface(
|
||||
private val activeTasks = context.taskManager.getActiveTasks()
|
||||
|
||||
override fun createTask(type: String, title: String, author: String, hash: String): String {
|
||||
val taskType = TaskType.fromKey(type)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(type),
|
||||
type = taskType,
|
||||
title = title,
|
||||
author = author.takeIf { it.isNotBlank() },
|
||||
hash = hash
|
||||
hash = hash,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
context.taskManager.createPendingTask(task)
|
||||
return hash
|
||||
|
||||
@@ -38,11 +38,13 @@ class TaskManager(
|
||||
private val activeTasks = mutableMapOf<Long, PendingTask>()
|
||||
|
||||
private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
|
||||
val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!)
|
||||
val task = Task(
|
||||
type = TaskType.fromKey(cursor.getStringOrNull("type")!!),
|
||||
type = taskType,
|
||||
title = cursor.getStringOrNull("title")!!,
|
||||
author = cursor.getStringOrNull("author"),
|
||||
hash = cursor.getStringOrNull("hash")!!
|
||||
hash = cursor.getStringOrNull("hash")!!,
|
||||
isAutoOpen = taskType == TaskType.CHAT_ACTION
|
||||
)
|
||||
task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
|
||||
task.extra = cursor.getStringOrNull("extra")
|
||||
@@ -127,15 +129,26 @@ class TaskManager(
|
||||
|
||||
fun getTaskByHash(hash: String?): Task? {
|
||||
if (hash == null) return null
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
return readTaskFromCursor(cursor)
|
||||
return runBlocking {
|
||||
suspendCoroutine { continuation ->
|
||||
queueExecutor.execute {
|
||||
runCatching {
|
||||
taskDatabase.rawQuery("SELECT * FROM tasks WHERE hash = ?", arrayOf(hash)).use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
continuation.resumeWith(Result.success(readTaskFromCursor(cursor)))
|
||||
} else {
|
||||
continuation.resumeWith(Result.success(null))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
continuation.resumeWith(Result.failure(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getActiveTasks() = activeTasks
|
||||
fun getActiveTasks(): Map<Long, PendingTask> = activeTasks
|
||||
|
||||
fun fetchStoredTasks(lastId: Long = Long.MAX_VALUE, limit: Int = 10): Map<Long, Task> {
|
||||
val tasks = mutableMapOf<Long, Task>()
|
||||
|
||||
@@ -59,6 +59,8 @@ class UpdateCheckWorker(
|
||||
|
||||
val intent = Intent(appContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra("show_changelog", true)
|
||||
putExtra("changelog_version", versionName)
|
||||
}
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -10,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
@@ -33,13 +36,17 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.SharedContextHolder
|
||||
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
|
||||
import me.eternal.purrfectsnap.common.ui.ThemeMode
|
||||
import me.eternal.purrfectsnap.common.ui.ThemePreferences
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
|
||||
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
|
||||
import android.content.IntentFilter
|
||||
|
||||
@@ -62,6 +69,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (::navController.isInitialized.not()) return
|
||||
handleAnnouncementIntent(intent)
|
||||
handleUpdateIntent(intent)
|
||||
|
||||
intent.getStringExtra("route")?.let { route ->
|
||||
navController.popBackStack()
|
||||
@@ -89,6 +98,8 @@ class MainActivity : ComponentActivity() {
|
||||
activity = this@MainActivity
|
||||
checkForRequirements()
|
||||
}
|
||||
handleAnnouncementIntent(intent)
|
||||
handleUpdateIntent(intent)
|
||||
val routes = Routes(managerContext)
|
||||
routes.activityLauncher = ActivityLauncherHelper(this)
|
||||
routes.getRoutes().forEach { it.init() }
|
||||
@@ -113,6 +124,31 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
AppMaterialTheme(themeMode = themeMode) {
|
||||
val shouldShowAbiWarning = remember {
|
||||
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
|
||||
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
|
||||
val appIsArm64 = libDir.contains("arm64")
|
||||
deviceIsArm64 && !appIsArm64
|
||||
}
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = managerContext.translation["wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = managerContext.translation["common.close"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = managerContext.translation["wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val isLight = background.luminance() > 0.5f
|
||||
val view = LocalView.current
|
||||
@@ -177,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)
|
||||
@@ -225,4 +278,22 @@ class MainActivity : ComponentActivity() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(restartReceiver)
|
||||
}
|
||||
|
||||
private fun handleAnnouncementIntent(intent: Intent) {
|
||||
if (intent.getBooleanExtra("show_announcements", false)) {
|
||||
applicationContext.getSharedPreferences("prefs", 0).edit()
|
||||
.putBoolean("show_announcements_on_launch", true)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateIntent(intent: Intent) {
|
||||
if (intent.getBooleanExtra("show_changelog", false)) {
|
||||
val version = intent.getStringExtra("changelog_version")
|
||||
applicationContext.getSharedPreferences("prefs", 0).edit()
|
||||
.putBoolean("show_changelog_on_launch", true)
|
||||
.putString("changelog_version_on_launch", version)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion.AphelionTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.themes.legacy.LegacyTheme
|
||||
|
||||
/**
|
||||
* ManagerTheme is the registry of all available themes.
|
||||
*
|
||||
* Adding a new theme requires changes only in this file:
|
||||
* - Add a new object entry to the sealed class
|
||||
* - Add its ID string to fromId()
|
||||
*
|
||||
* No other file needs to change when a new theme is added.
|
||||
*/
|
||||
sealed class ManagerTheme(val theme: ThemeContract) {
|
||||
object Legacy : ManagerTheme(LegacyTheme)
|
||||
object Aphelion : ManagerTheme(AphelionTheme)
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String): ManagerTheme = when (id) {
|
||||
"APHELION" -> Aphelion
|
||||
else -> Legacy // default fallback is always Legacy
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -105,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.navigation
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
|
||||
import kotlin.math.round
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
@@ -116,28 +116,44 @@ import kotlin.math.sin
|
||||
androidx.compose.animation.ExperimentalAnimationApi::class
|
||||
)
|
||||
class Navigation(
|
||||
private val context: RemoteSideContext,
|
||||
internal val context: RemoteSideContext,
|
||||
private val navController: NavHostController,
|
||||
val routes: Routes = Routes(context).also { it.navController = navController }
|
||||
) {
|
||||
private val translation by lazy { context.translation.getCategory("manager.navigation") }
|
||||
var openBottomBarCustomization by mutableStateOf(false)
|
||||
var globalScrollOffset by mutableIntStateOf(0)
|
||||
val themeRevealState = ThemeRevealState()
|
||||
|
||||
@Composable
|
||||
fun TopBar() {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
|
||||
|
||||
val canGoBack = remember(navBackStackEntry) {
|
||||
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
|
||||
}
|
||||
val haptic = LocalHapticFeedback.current
|
||||
TopAppBar(
|
||||
modifier = Modifier.height(headerHeight),
|
||||
title = {
|
||||
currentRoute?.apply {
|
||||
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = 1f - (focusFactor * 0.05f)
|
||||
scaleY = 1f - (focusFactor * 0.05f)
|
||||
translationY = (-2 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -146,11 +162,20 @@ class Navigation(
|
||||
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.graphicsLayer { alpha = backButtonAnimation }
|
||||
.graphicsLayer {
|
||||
alpha = backButtonAnimation
|
||||
scaleX = 1f - (focusFactor * 0.1f)
|
||||
scaleY = 1f - (focusFactor * 0.1f)
|
||||
}
|
||||
.width(lerp(0.dp, 48.dp, backButtonAnimation))
|
||||
.height(48.dp)
|
||||
) {
|
||||
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
|
||||
IconButton(onClick = {
|
||||
if (canGoBack) {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
navController.popBackStack()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
|
||||
}
|
||||
}
|
||||
@@ -162,21 +187,34 @@ class Navigation(
|
||||
actions = {
|
||||
currentRoute?.topBarActions?.invoke(this)
|
||||
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
|
||||
IconButton(onClick = { openBottomBarCustomization = true }) {
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
openBottomBarCustomization = true
|
||||
}) {
|
||||
Icon(Icons.Filled.Tune, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingBottomBar() {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
|
||||
val availableRoutes = remember {
|
||||
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
|
||||
}
|
||||
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
|
||||
|
||||
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
|
||||
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
|
||||
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
|
||||
val barHeight = lerp(82.dp, 64.dp, focusFactor)
|
||||
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val iconTranslationY = (10 * focusFactor).dp
|
||||
|
||||
val prefs = remember { context.sharedPreferences }
|
||||
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
|
||||
fun loadSelected(): List<String> {
|
||||
@@ -233,9 +271,17 @@ class Navigation(
|
||||
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
|
||||
Surface(
|
||||
shape = barShape,
|
||||
color = Color.Transparent,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
|
||||
.drawBehind {
|
||||
@@ -263,7 +309,7 @@ class Navigation(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.height(barHeight)
|
||||
.clip(barShape)
|
||||
.background(PurrfectPalette.cardOverlay)
|
||||
.border(BorderStroke(1.dp, barBorder), barShape)
|
||||
@@ -296,16 +342,16 @@ class Navigation(
|
||||
)
|
||||
}
|
||||
)
|
||||
Box(Modifier.fillMaxWidth().height(82.dp)) {
|
||||
Box(Modifier.fillMaxWidth().height(barHeight)) {
|
||||
var barWidthPx by remember { mutableStateOf(0f) }
|
||||
val itemCount = selectedRoutes.size.coerceAtLeast(1)
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val selectedIndex = remember(currentRoute, selectedRoutes) {
|
||||
val index = selectedRoutes.indexOf(currentRoute)
|
||||
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
|
||||
if (index >= 0) index else null
|
||||
}
|
||||
|
||||
selectedIndex?.let { // Null check
|
||||
selectedIndex?.let {
|
||||
val itemWidthPx =
|
||||
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
|
||||
val offsetAnim = remember { Animatable(0f) }
|
||||
@@ -369,7 +415,7 @@ class Navigation(
|
||||
.fillMaxHeight()
|
||||
.width(indicatorWidth.coerceAtLeast(0.dp))
|
||||
.offset(x = offsetX)
|
||||
.padding(vertical = 10.dp, horizontal = 2.dp)
|
||||
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
|
||||
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
|
||||
) {
|
||||
Box(
|
||||
@@ -430,7 +476,10 @@ class Navigation(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(22.dp + 2.dp * selectionProgress)
|
||||
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
|
||||
.graphicsLayer {
|
||||
alpha = 0.65f + 0.35f * selectionProgress
|
||||
translationY = iconTranslationY.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
label = {
|
||||
@@ -441,11 +490,15 @@ class Navigation(
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
|
||||
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
|
||||
maxLines = if (isLong) 2 else 1,
|
||||
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
|
||||
softWrap = isLong,
|
||||
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
|
||||
.graphicsLayer {
|
||||
alpha = labelAlpha
|
||||
translationY = (-10 * focusFactor).dp.toPx()
|
||||
}
|
||||
)
|
||||
},
|
||||
selected = isSelected,
|
||||
@@ -456,7 +509,10 @@ class Navigation(
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.72f),
|
||||
indicatorColor = Color.Transparent
|
||||
),
|
||||
onClick = { route.navigateReset() }
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
route.navigateReset()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -545,7 +601,7 @@ class Navigation(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
} else {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val hapticCustom = LocalHapticFeedback.current
|
||||
var draggingId by remember { mutableStateOf<String?>(null) }
|
||||
var dragDelta by remember { mutableStateOf(0f) }
|
||||
var dragStartIndex by remember { mutableStateOf(-1) }
|
||||
@@ -577,7 +633,7 @@ class Navigation(
|
||||
draggingId = id
|
||||
dragStartIndex = selectedTabIds.indexOf(id)
|
||||
dragDelta = 0f
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
|
||||
},
|
||||
onDrag = { _: PointerInputChange, dragAmount ->
|
||||
dragDelta += dragAmount.y
|
||||
@@ -591,7 +647,7 @@ class Navigation(
|
||||
list.add(targetIndex, id)
|
||||
selectedTabIds = list
|
||||
saveSelected(selectedTabIds)
|
||||
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -663,7 +719,7 @@ class Navigation(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
) {
|
||||
availableRoutes.forEach { route ->
|
||||
val id = route.routeInfo.id
|
||||
val already = selectedTabIds.contains(id)
|
||||
@@ -794,7 +850,9 @@ class Navigation(
|
||||
} else {
|
||||
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
|
||||
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
|
||||
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
|
||||
children.forEach { child ->
|
||||
composable(child.routeInfo.id) { child.content.invoke(it) }
|
||||
}
|
||||
route.customComposables.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun rememberRouteScrollState(key: String): ScrollState {
|
||||
val scrollState = remember(key) {
|
||||
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
|
||||
}
|
||||
LaunchedEffect(key, scrollState.value) {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
DisposableEffect(key, scrollState) {
|
||||
onDispose {
|
||||
RouteStateCache.scrollOffsets[key] = scrollState.value
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberRouteLazyListState(key: String): LazyListState {
|
||||
val savedState = RouteStateCache.lazyListOffsets[key]
|
||||
val listState = remember(key) {
|
||||
LazyListState(
|
||||
firstVisibleItemIndex = savedState?.first ?: 0,
|
||||
firstVisibleItemScrollOffset = savedState?.second ?: 0
|
||||
)
|
||||
}
|
||||
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
DisposableEffect(key, listState) {
|
||||
onDispose {
|
||||
RouteStateCache.lazyListOffsets[key] =
|
||||
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
return listState
|
||||
}
|
||||
|
||||
private object RouteStateCache {
|
||||
val scrollOffsets = mutableMapOf<String, Int>()
|
||||
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class Routes(
|
||||
) {
|
||||
companion object {
|
||||
const val CONFIG_IMPORT_CONFIRMATION_ROUTE = "config_import_confirmation"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}"
|
||||
const val CONFIG_EXPORT_SUMMARY_ROUTE = "config_export_summary/?exportSensitiveData={exportSensitiveData}&includeSavedLocations={includeSavedLocations}"
|
||||
const val FRIEND_TRACKER_CONFIG_EXPORT_ROUTE = "friend_tracker_config_export/?rule_id={rule_id}"
|
||||
const val FRIEND_TRACKER_CONFIG_IMPORT_ROUTE = "friend_tracker_config_import"
|
||||
const val VIEW_LOGGER_HISTORY_ROUTE = "view_logger_history/{uri}"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package me.eternal.purrfectsnap.ui.manager
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
|
||||
/**
|
||||
* ThemeContract defines the visual layout contract every theme must fulfill.
|
||||
*/
|
||||
interface ThemeContract {
|
||||
@Composable fun HomeRootSection.HomeScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeSettings.SettingsScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeAbout.AboutScreen(nav: NavBackStackEntry)
|
||||
@Composable fun HomeLogs.LogsScreen(nav: NavBackStackEntry)
|
||||
@Composable fun SocialRootSection.SocialScreen(nav: NavBackStackEntry)
|
||||
@Composable fun TasksRootSection.TasksScreen(nav: NavBackStackEntry)
|
||||
@Composable fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry)
|
||||
@Composable fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry)
|
||||
@Composable fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry)
|
||||
}
|
||||
@@ -1,125 +1,284 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
|
||||
@Immutable
|
||||
data class FloatingTopBarColors(
|
||||
val container: Color,
|
||||
val border: Brush
|
||||
val borderStart: Color,
|
||||
val borderEnd: Color
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
|
||||
return remember {
|
||||
FloatingTopBarColors(
|
||||
container = Color.White.copy(alpha = 0.07f),
|
||||
border = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
container = Color.White.copy(alpha = 0.12f),
|
||||
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
|
||||
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified Floating Top Bar for Aphelion.
|
||||
* Handles the signature morphing animation and provides a "Bottom Content" slot.
|
||||
*/
|
||||
@Composable
|
||||
fun FloatingTopBar(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollOffset: Int = 0,
|
||||
enableMorph: Boolean = false,
|
||||
containerAlpha: Float = 1f,
|
||||
titleAlignment: Alignment.Horizontal = Alignment.Start,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
|
||||
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
|
||||
) {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = colors.container,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, colors.border)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val focusFactor by remember(scrollOffset, enableMorph) {
|
||||
derivedStateOf {
|
||||
if (!enableMorph) 0f
|
||||
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
|
||||
val morphingParams by remember(focusFactor, statusBarHeight) {
|
||||
derivedStateOf {
|
||||
object {
|
||||
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
|
||||
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
|
||||
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
|
||||
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
|
||||
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
|
||||
val topCorners = lerp(26.dp, 0.dp, focusFactor)
|
||||
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
|
||||
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
|
||||
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
|
||||
val iconScale = 1f - (0.12f * focusFactor)
|
||||
val horizontalShift = (6 * focusFactor).dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hasSnapped by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(focusFactor) {
|
||||
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
hasSnapped = true
|
||||
} else if (focusFactor < 0.5f) {
|
||||
hasSnapped = false
|
||||
}
|
||||
}
|
||||
|
||||
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
|
||||
RoundedCornerShape(
|
||||
topStart = morphingParams.topCorners,
|
||||
topEnd = morphingParams.topCorners,
|
||||
bottomStart = morphingParams.bottomCorners,
|
||||
bottomEnd = morphingParams.bottomCorners
|
||||
)
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
val refractiveColor = remember { Color(0xFF241F52) }
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
|
||||
1.0f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = morphingParams.sidePadding)
|
||||
.padding(top = morphingParams.containerTopPadding)
|
||||
.graphicsLayer {
|
||||
alpha = containerAlpha
|
||||
},
|
||||
shape = shape,
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = (4 * focusFactor).dp
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
|
||||
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(0.dp))
|
||||
}
|
||||
.drawBehind {
|
||||
val strokeWidth = 1.dp.toPx()
|
||||
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
|
||||
val tr = morphingParams.topCorners.toPx()
|
||||
val br = morphingParams.bottomCorners.toPx()
|
||||
|
||||
if (focusFactor > 0.9f) {
|
||||
uPath.reset()
|
||||
uPath.apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(0f, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
|
||||
lineTo(size.width - br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
|
||||
lineTo(size.width, 0f)
|
||||
}
|
||||
drawPath(uPath, brush, style = Stroke(strokeWidth))
|
||||
} else {
|
||||
borderPath.reset()
|
||||
borderPath.apply {
|
||||
moveTo(tr, 0f)
|
||||
lineTo(size.width - tr, 0f)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
|
||||
lineTo(size.width, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
|
||||
lineTo(br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
|
||||
lineTo(0f, tr)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
|
||||
}
|
||||
drawPath(borderPath, brush, style = Stroke(strokeWidth))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = morphingParams.internalTopPadding)
|
||||
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
|
||||
.height(morphingParams.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onBack()
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
translationX = -morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = titleAlignment
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 19.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
|
||||
PurrfectMarqueeText(
|
||||
text = subtitle,
|
||||
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
|
||||
style = TextStyle(fontSize = 13.sp),
|
||||
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
|
||||
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
|
||||
enabled = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
translationY = morphingParams.subtitleTranslationY.toPx()
|
||||
alpha = morphingParams.subtitleAlpha
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.CenterEnd) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = actions
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = morphingParams.iconScale
|
||||
scaleY = morphingParams.iconScale
|
||||
if (onBack != null) {
|
||||
translationX = morphingParams.horizontalShift.toPx()
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
|
||||
bottomContent(focusFactor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object UpdateDownloader {
|
||||
private const val TAG = "UpdateDownloader"
|
||||
private var fetch: Fetch? = null
|
||||
private var listener: FetchListener? = null
|
||||
private val fallbackHttpClient by lazy { OkHttpClient() }
|
||||
|
||||
private fun getInstance(context: RemoteSideContext): Fetch {
|
||||
fetch?.let { return it }
|
||||
@@ -84,6 +87,132 @@ object UpdateDownloader {
|
||||
return downloadedFile
|
||||
}
|
||||
|
||||
private fun scheduleReset(scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
delay(2000)
|
||||
downloadState.value = DownloadState.IDLE
|
||||
downloadProgress.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun installDownloadedFile(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadedFile: File,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.COMPLETED
|
||||
runCatching {
|
||||
remoteContext.log.info(
|
||||
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
|
||||
TAG
|
||||
)
|
||||
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
|
||||
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
val installIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
|
||||
context.startActivity(installIntent)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(30_000)
|
||||
runCatching { downloadedFile.delete() }
|
||||
apkFile.parentFile
|
||||
?.takeIf { it.name == "update" }
|
||||
?.let { dir -> runCatching { dir.deleteRecursively() } }
|
||||
remoteContext.log.info("Cleaned downloaded update files", TAG)
|
||||
}
|
||||
}.onFailure {
|
||||
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
|
||||
remoteContext.log.error("Failed to install downloaded update", it, TAG)
|
||||
downloadState.value = DownloadState.FAILED
|
||||
}
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun failDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
scope: CoroutineScope,
|
||||
errorMessage: String,
|
||||
throwable: Throwable? = null
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
downloadState.value = DownloadState.FAILED
|
||||
Toast.makeText(
|
||||
context,
|
||||
translation.format("update_download_failed_toast", "error" to errorMessage),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
|
||||
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
|
||||
scheduleReset(scope)
|
||||
}
|
||||
|
||||
private fun startHttpFallbackDownload(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
filePath: String,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val partialFile = File("$filePath.part")
|
||||
val outputFile = File(filePath)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
|
||||
partialFile.parentFile?.mkdirs()
|
||||
if (partialFile.exists()) partialFile.delete()
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(downloadUrl)
|
||||
.header("User-Agent", "PurrfectSnap-Updater")
|
||||
.build()
|
||||
|
||||
fallbackHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("HTTP_${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
|
||||
val contentLength = body.contentLength()
|
||||
var downloadedBytes = 0L
|
||||
|
||||
body.byteStream().use { input ->
|
||||
partialFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
output.write(buffer, 0, read)
|
||||
downloadedBytes += read
|
||||
if (contentLength > 0) {
|
||||
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!partialFile.renameTo(outputFile)) {
|
||||
partialFile.copyTo(outputFile, overwrite = true)
|
||||
partialFile.delete()
|
||||
}
|
||||
installDownloadedFile(remoteContext, outputFile, scope)
|
||||
}.onFailure {
|
||||
runCatching { partialFile.delete() }
|
||||
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall(
|
||||
remoteContext: RemoteSideContext,
|
||||
downloadUrl: String,
|
||||
@@ -91,6 +220,7 @@ object UpdateDownloader {
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = remoteContext.androidContext
|
||||
val translation = remoteContext.translation.getCategory("manager.sections.home")
|
||||
val fetch = getInstance(remoteContext)
|
||||
val filePath = File(context.externalCacheDir, fileName).path
|
||||
remoteContext.log.info("Starting update download from $downloadUrl -> $filePath", TAG)
|
||||
@@ -99,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
|
||||
@@ -107,7 +238,7 @@ object UpdateDownloader {
|
||||
|
||||
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
|
||||
downloadState.value = DownloadState.DOWNLOADING
|
||||
Toast.makeText(context, "Download started", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, translation["update_download_started_toast"], Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) {
|
||||
@@ -115,56 +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, "Download completed", 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, "Failed to install update. Check logs for more details.", 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, "Download failed: $error", 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!!)
|
||||
|
||||
@@ -143,7 +143,7 @@ class ManageReposSection: Routes.Route() {
|
||||
addRepo(url)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -136,10 +137,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"] ?: "Export"
|
||||
val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
|
||||
val exportLabel = context.translation["manager.sections.features.export_option"]
|
||||
val parser = remember { ConfigParser() }
|
||||
val savedLocations = remember {
|
||||
if (includeSavedLocations) context.database.getLocationCoordinates() else null
|
||||
}
|
||||
val featuresByCategory = remember {
|
||||
parser.parse(context.config.exportToString(exportSensitiveData))
|
||||
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
@@ -191,14 +196,14 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -210,7 +215,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
|
||||
context.config.writeConfig()
|
||||
context.config.exportToString(exportSensitiveData).byteInputStream().copyTo(it)
|
||||
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
|
||||
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
|
||||
}
|
||||
}.onFailure {
|
||||
|
||||
@@ -49,10 +49,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
|
||||
import me.eternal.purrfectsnap.storage.getLocationCoordinates
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.abs
|
||||
|
||||
class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.features.config_import") }
|
||||
@@ -65,6 +69,44 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
val indentation: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports saved locations from JSON array into database with de-duplication.
|
||||
* Only adds locations that don't already exist (within coordinate tolerance).
|
||||
*/
|
||||
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
|
||||
val existingLocations = context.database.getLocationCoordinates()
|
||||
|
||||
for (i in 0 until locationsArray.size()) {
|
||||
val locationObj = locationsArray.get(i).asJsonObject
|
||||
val name = locationObj.get("name")?.asString ?: continue
|
||||
val latitude = locationObj.get("latitude")?.asDouble ?: continue
|
||||
val longitude = locationObj.get("longitude")?.asDouble ?: continue
|
||||
val radius = locationObj.get("radius")?.asDouble ?: 100.0
|
||||
|
||||
// Check for existing location with similar coordinates (de-duplication)
|
||||
val existingMatch = existingLocations.find { existing ->
|
||||
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
|
||||
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
|
||||
}
|
||||
|
||||
if (existingMatch == null) {
|
||||
// No duplicate found, add as new location
|
||||
val newLocation = LocationCoordinates().apply {
|
||||
this.name = name
|
||||
this.latitude = latitude
|
||||
this.longitude = longitude
|
||||
this.radius = radius
|
||||
}
|
||||
context.database.addOrUpdateLocationCoordinate(null, newLocation)
|
||||
}
|
||||
// If duplicate exists, skip (do not update or delete existing)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConfigParser {
|
||||
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
|
||||
val featureList = mutableListOf<ImportedFeature>()
|
||||
@@ -190,7 +232,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
routes.configJsonForImport?.let { parser.parse(it) } ?: emptyMap()
|
||||
}
|
||||
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
|
||||
val importLabel = translation["confirm_button"] ?: "Import"
|
||||
val importLabel = translation["confirm_button"]
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -239,14 +281,14 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(context.translation["common.back"] ?: "Back")
|
||||
Text(context.translation["common.back"])
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Summary",
|
||||
text = translation["title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -256,12 +298,17 @@ class ConfigImportConfirmationScreen : Routes.Route() {
|
||||
onClick = {
|
||||
routes.configJsonForImport?.let { json ->
|
||||
runCatching {
|
||||
context.config.loadFromString(json)
|
||||
val savedLocationsJson = context.config.loadFromString(json)
|
||||
|
||||
// Import saved locations if present in the JSON
|
||||
savedLocationsJson?.let { locationsArray ->
|
||||
importSavedLocations(locationsArray)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
context.longToast(
|
||||
context.translation.format(
|
||||
"config_import_failure_toast",
|
||||
"error" to (err.message ?: "Unknown error")
|
||||
"error" to (err.message ?: context.translation["common.unknown_error"])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
|
||||
title = translation["clear_list_button"],
|
||||
text = translation["dialog_clear_confirmation_text"],
|
||||
icon = Icons.Default.DeleteSweep,
|
||||
confirmButtonText = "Clear",
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
confirmButtonText = translation["dialog_clear_confirm_button"],
|
||||
dismissButtonText = translation["dialog_clear_cancel_button"],
|
||||
onDismiss = { confirmationDialog = false },
|
||||
onConfirm = {
|
||||
context.database.clearRuleIds(currentRuleType.key)
|
||||
@@ -241,7 +242,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
.fillMaxSize()
|
||||
.padding(top = topBarHeight + 10.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val headerShape = RoundedCornerShape(22.dp)
|
||||
@@ -390,7 +391,7 @@ class ManageRuleFeature : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Clear")
|
||||
Text(text = translation["dialog_clear_confirm_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,334 +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.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))
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember {
|
||||
"""
|
||||
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, SUJΛL, Zain & scrodingerspet, 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.
|
||||
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
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) }
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
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 = bottomPadding)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: "About",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LaunchedEffect(Unit) {
|
||||
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
|
||||
}
|
||||
|
||||
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 = "PurrfectSnap",
|
||||
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 = "An Xposed Module meant to enhance your Snapchat experience!",
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Lead Developers",
|
||||
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 = "Our Story",
|
||||
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 = "With love, PurrfectSnap Team",
|
||||
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") },
|
||||
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 = "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
|
||||
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 = "Telegram", 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import me.eternal.purrfectsnap.LogReader
|
||||
import me.eternal.purrfectsnap.common.logger.LogChannel
|
||||
import me.eternal.purrfectsnap.common.logger.LogLevel
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -73,14 +74,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||
|
||||
class HomeLogs : Routes.Route() {
|
||||
private val logListState by lazy { LazyListState(0) }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
private val externalRefreshTick = mutableStateOf(0)
|
||||
internal val logListState = LazyListState()
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val externalRefreshTick = mutableIntStateOf(0)
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
private fun clearLogsAndReload() {
|
||||
internal fun clearLogsAndReload() {
|
||||
context.coroutineScope.launch {
|
||||
context.log.clearLogs()
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -89,7 +90,7 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportLogs() {
|
||||
internal fun exportLogs() {
|
||||
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
|
||||
context.coroutineScope.launch {
|
||||
context.shortToast(translation["saving_logs_toast"])
|
||||
@@ -107,118 +108,13 @@ class HomeLogs : Routes.Route() {
|
||||
}
|
||||
|
||||
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<LogLine>() }
|
||||
val mainExecutor = remember { context.androidContext.mainExecutor }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
fun refreshLogs() {
|
||||
coroutineScope.launch {
|
||||
val readerResult = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
mainExecutor.execute {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast("Failed to read logs!")
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
val filteredLogs = withContext(Dispatchers.IO) {
|
||||
(0 until reader.lineCount).mapNotNull { index ->
|
||||
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
|
||||
}
|
||||
}
|
||||
visibleLogs.clear()
|
||||
visibleLogs.addAll(filteredLogs)
|
||||
}
|
||||
delay(220)
|
||||
if (visibleLogs.isNotEmpty()) {
|
||||
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
|
||||
logListState.scrollToItem(targetIndex)
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
}
|
||||
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
.pullRefresh(pullRefreshState)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
LogsFloatingBar(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
onExport = { exportLogs() },
|
||||
onClear = { clearLogsAndReload() }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (visibleLogs.isEmpty() && logReader != null) {
|
||||
EmptyLogsState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = logListState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 12.dp,
|
||||
bottom = routes.bottomPadding + 22.dp
|
||||
)
|
||||
) {
|
||||
items(visibleLogs, key = { it.hashCode() }) { line ->
|
||||
LogEntryCard(line = line, composeContext = composeContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp)
|
||||
)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
|
||||
}
|
||||
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
|
||||
}
|
||||
|
||||
override val floatingActionButton: @Composable () -> Unit = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(
|
||||
@@ -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"] ?: "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()
|
||||
@@ -412,7 +313,7 @@ class HomeLogs : Routes.Route() {
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "Pull to refresh or trigger an action to see new entries.",
|
||||
text = translation["refresh_hint"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -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") ||
|
||||
|
||||
@@ -46,14 +46,15 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.outlined.Widgets
|
||||
@@ -81,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
|
||||
@@ -91,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
|
||||
@@ -121,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
|
||||
@@ -136,6 +142,7 @@ class HomeRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
|
||||
|
||||
companion object {
|
||||
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
|
||||
val cardMargin = 10.dp
|
||||
val pageBackgroundGradient = Brush.verticalGradient(
|
||||
listOf(
|
||||
@@ -146,22 +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"
|
||||
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"]
|
||||
@@ -173,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 {
|
||||
@@ -188,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) {
|
||||
@@ -211,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
|
||||
}
|
||||
@@ -241,7 +253,7 @@ class HomeRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroBadge(text: String) {
|
||||
internal fun HeroBadge(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color.White,
|
||||
@@ -257,7 +269,8 @@ class HomeRootSection : Routes.Route() {
|
||||
@Composable
|
||||
private fun TopBarActionChip(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
label: String? = null,
|
||||
contentDescription: String? = label,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
@@ -273,13 +286,17 @@ class HomeRootSection : Routes.Route() {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = label, tint = Color.White)
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Icon(icon, contentDescription = contentDescription, tint = Color.White)
|
||||
label?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,9 +328,9 @@ class HomeRootSection : Routes.Route() {
|
||||
label = context.translation["manager.routes.home_logs"]
|
||||
) { routes.homeLogs.navigate() }
|
||||
TopBarActionChip(
|
||||
icon = Icons.Filled.Settings,
|
||||
label = context.translation["manager.routes.home_settings"]
|
||||
) { routes.settings.navigate() }
|
||||
icon = Icons.Filled.Info,
|
||||
label = translation["manager.routes.home_about"]
|
||||
) { routes.about.navigate() }
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +418,7 @@ class HomeRootSection : Routes.Route() {
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onWikiClick: () -> Unit,
|
||||
onWebsiteClick: () -> Unit,
|
||||
onTelegramClick: () -> Unit,
|
||||
onGithubClick: () -> Unit,
|
||||
authorName: String,
|
||||
@@ -446,7 +463,7 @@ class HomeRootSection : Routes.Route() {
|
||||
fontFamily = avenirNext
|
||||
)
|
||||
Text(
|
||||
text = "An Xposed Module meant to enhance your Snapchat experience",
|
||||
text = translation["hero_tagline"],
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
@@ -458,9 +475,9 @@ class HomeRootSection : Routes.Route() {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
HeroBadge("Version: $versionName - $channelLabel")
|
||||
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
|
||||
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
|
||||
HeroBadge("Build: $it")
|
||||
HeroBadge(translation.format("hero_build_label", "build" to it))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +576,7 @@ class HomeRootSection : Routes.Route() {
|
||||
tint = Color(0xFFA3F0C2)
|
||||
)
|
||||
Text(
|
||||
text = "Ready to install",
|
||||
text = translation["update_ready_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -604,7 +621,7 @@ class HomeRootSection : Routes.Route() {
|
||||
.background(if (isPurrAuraActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3))
|
||||
)
|
||||
Text(
|
||||
text = if (isPurrAuraActive) "PurrAura Active!" else "PurrAura Inactive",
|
||||
text = if (isPurrAuraActive) translation["purr_aura_active_label"] else translation["purr_aura_inactive_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp
|
||||
@@ -616,11 +633,11 @@ 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))
|
||||
Text("Open Settings")
|
||||
Text(translation["open_settings_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -642,15 +659,15 @@ class HomeRootSection : Routes.Route() {
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onWikiClick,
|
||||
onClick = onWebsiteClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = Color(0xFF1B152E)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "Wiki", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -665,7 +682,7 @@ class HomeRootSection : Routes.Route() {
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(text = "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(text = translation["github_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
ExternalLinkIcon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
|
||||
@@ -678,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
|
||||
}
|
||||
@@ -696,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()
|
||||
@@ -724,449 +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 selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
|
||||
context.database.getQuickTiles().filter { it.isNotBlank() }
|
||||
}
|
||||
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
|
||||
val channelLabel = if (updateChannel == "prerelease") "Pre-release" else "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) }
|
||||
|
||||
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,
|
||||
"Your device architecture is not supported for automatic updates.",
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
HomeActionChips()
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
HeroSection(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
latestUpdate = latestUpdate,
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = onUpdateButtonClick,
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onWikiClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap/wiki") },
|
||||
onTelegramClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official") },
|
||||
onGithubClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap") },
|
||||
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 = "No quick tiles yet",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Design your dream grid with the actions you use the most.",
|
||||
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 = "Add tile")
|
||||
}
|
||||
}
|
||||
} 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 = "${selectedTiles.size} curated shortcuts",
|
||||
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 = "Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
val spacing = 12.dp
|
||||
val gridPadding = 8.dp
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val preferredTileWidth = 100.dp
|
||||
val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing))
|
||||
.toInt()
|
||||
.coerceAtLeast(2)
|
||||
.coerceAtMost(4)
|
||||
val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns
|
||||
val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = gridPadding),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
maxItemsInEachRow = columns
|
||||
) {
|
||||
selectedTiles.forEach { tileName ->
|
||||
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach
|
||||
val (card, action) = cardEntry
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.width(tileWidth)
|
||||
.aspectRatio(1.05f)
|
||||
.scaleOnPress(interactionSource)
|
||||
.clickable { action(routes) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.22f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clipToBounds()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(all = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = card.second, contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(44.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = card.first,
|
||||
lineHeight = 16.sp,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = Color.White,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showChangelogDialog && latestUpdate != null) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Update",
|
||||
onConfirm = {
|
||||
showChangelogDialog = false
|
||||
handleUpdateAction()
|
||||
},
|
||||
dismissButtonText = "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
confirmEnabled = !changelogLoading,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 340.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
when {
|
||||
changelogLoading -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Loading changelog…",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
changelogError != null -> {
|
||||
Text(
|
||||
text = changelogError ?: "Failed to load changelog",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = changelogText ?: "Changelog not available",
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
selectedQuickActions = selectedTiles,
|
||||
onDismiss = { showQuickActionsMenu = false },
|
||||
onSave = { newList ->
|
||||
val previous = selectedTiles.toList()
|
||||
val removed = previous.filter { it !in newList }
|
||||
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
|
||||
newList.forEach { clearTileOffset(it) }
|
||||
selectedTiles.clear()
|
||||
selectedTiles.addAll(newList)
|
||||
context.coroutineScope.launch {
|
||||
context.database.setQuickTiles(selectedTiles)
|
||||
}
|
||||
showQuickActionsMenu = false
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@HomeRootSection.HomeScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
internal fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
val lines = raw.lines()
|
||||
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
|
||||
val collected = mutableListOf<String>()
|
||||
@@ -1185,3 +776,7 @@ private fun extractChangelogForVersion(raw: String, version: String): String {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -32,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -43,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
@@ -51,6 +53,8 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.task.UpdateCheckWorker
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -65,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
|
||||
@@ -125,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),
|
||||
@@ -138,16 +162,22 @@ class HomeSettings : Routes.Route() {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PremiumPreferenceToggle(
|
||||
internal fun PremiumPreferenceToggle(
|
||||
sharedPreferences: SharedPreferences,
|
||||
key: String,
|
||||
text: String,
|
||||
defaultValue: Boolean = false
|
||||
defaultValue: Boolean = false,
|
||||
confirmDisableTitle: String? = null,
|
||||
confirmDisableText: String? = null
|
||||
) {
|
||||
val realKey = "debug_$key"
|
||||
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
|
||||
var showDisableDialog by remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val positiveLabel = context.translation["button.positive"]
|
||||
val negativeLabel = context.translation["button.negative"]
|
||||
|
||||
LaunchedEffect(realKey) {
|
||||
if (!sharedPreferences.contains(realKey)) {
|
||||
@@ -156,6 +186,24 @@ class HomeSettings : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
if (showDisableDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showDisableDialog = false },
|
||||
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
|
||||
text = confirmDisableText.orEmpty(),
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = positiveLabel,
|
||||
dismissButtonText = negativeLabel,
|
||||
onConfirm = {
|
||||
value = false
|
||||
sharedPreferences.edit().putBoolean(realKey, false).apply()
|
||||
showDisableDialog = false
|
||||
},
|
||||
onDismiss = { showDisableDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -164,11 +212,15 @@ class HomeSettings : Routes.Route() {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
value = !value
|
||||
sharedPreferences
|
||||
.edit() {
|
||||
val nextValue = !value
|
||||
if (!nextValue && confirmDisableTitle != null) {
|
||||
showDisableDialog = true
|
||||
} else {
|
||||
value = nextValue
|
||||
sharedPreferences.edit() {
|
||||
putBoolean(realKey, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -184,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
|
||||
@@ -216,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)
|
||||
}
|
||||
@@ -264,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,
|
||||
@@ -278,628 +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 sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.12f),
|
||||
@Composable
|
||||
internal fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
@Composable
|
||||
fun GlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AestheticDropdownField(
|
||||
value: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = value, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
),
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = translation["manager.routes.home_settings"],
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction ->
|
||||
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
|
||||
}
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["haptic_feedback_label"])
|
||||
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = hapticFeedbackEnabled,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
hapticFeedbackEnabled = it
|
||||
context.config.root.global.uiSettings.hapticFeedback.set(it)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["use_system_toasts_label"])
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = useSystemToasts,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
useSystemToasts = it
|
||||
context.config.root.global.uiSettings.useSystemToasts.set(it)
|
||||
context.config.writeConfig()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = translation["auto_update_check"])
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
Switch(
|
||||
checked = autoUpdateCheck,
|
||||
onCheckedChange = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
autoUpdateCheck = it
|
||||
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
},
|
||||
modifier = Modifier.padding(end = 26.dp),
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
val spacingModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 26.dp)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = channelMenuExpanded,
|
||||
onExpandedChange = { channelMenuExpanded = it },
|
||||
modifier = spacingModifier
|
||||
) {
|
||||
AestheticDropdownField(
|
||||
value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel,
|
||||
expanded = channelMenuExpanded,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
onClick = { channelMenuExpanded = true }
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = channelMenuExpanded,
|
||||
onDismissRequest = { channelMenuExpanded = false }
|
||||
) {
|
||||
listOf("stable", "prerelease").forEach { channel ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) },
|
||||
onClick = {
|
||||
selectedChannel = channel
|
||||
channelMenuExpanded = false
|
||||
context.config.root.global.updateSettings.updateChannel.set(channel)
|
||||
context.config.writeConfig()
|
||||
scheduleUpdateCheck()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = "Reset PurrfectSnap")
|
||||
ShiftedRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 55.dp)
|
||||
.clickable {
|
||||
// Clear setup progress and route back to SetupActivity
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
|
||||
// Clear config to defaults
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
|
||||
// Launch setup activity fresh
|
||||
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
|
||||
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
|
||||
// Close current manager activity
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Reset and restart setup",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "Reset",
|
||||
modifier = Modifier.padding(end = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredMessageCount()
|
||||
}
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
|
||||
context.messageLogger.getStoredStoriesCount()
|
||||
}
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val summary = translation.format(
|
||||
"message_logger_summary",
|
||||
"messageCount" to storedMessagesCount.toString(),
|
||||
"storyCount" to storedStoriesCount.toString()
|
||||
).replace("\n", " | ")
|
||||
Text(
|
||||
summary,
|
||||
maxLines = 2,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
|
||||
context.messageLogger.databaseFile.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to export database", it)
|
||||
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["export_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db")
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
FileOutputStream(tempFile).use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
routes.viewLoggerHistory.navigate {
|
||||
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file", it)
|
||||
context.longToast("Failed to open file! ${it.localizedMessage}")
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["view_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.messageLogger.purgeAll()
|
||||
storedMessagesCount = 0
|
||||
storedStoriesCount = 0
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear messages", it)
|
||||
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["clear_button"])
|
||||
}
|
||||
Button(
|
||||
onClick = { showImportDialog = true },
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = "Import")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(5.dp),
|
||||
onClick = { routes.loggerHistory.navigate() },
|
||||
colors = sharedOutlinedColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
|
||||
) {
|
||||
Text(translation["view_logger_history_button"])
|
||||
}
|
||||
if (showImportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportDialog = false },
|
||||
title = { Text("Import message logger") },
|
||||
text = { Text("Importing will override your current message logger database. Continue?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showImportDialog = false
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/octet-stream") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
|
||||
context.messageLogger.databaseFile.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
} ?: throw IllegalStateException("Unable to open selected file")
|
||||
storedMessagesCount = context.messageLogger.getStoredMessageCount()
|
||||
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
|
||||
context.shortToast(translation["success_toast"])
|
||||
context.log.info("Imported message logger from $uri", "MessageLogger")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import message logger", it)
|
||||
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to launch import picker", it)
|
||||
context.longToast("Import failed: ${it.localizedMessage ?: it.message}")
|
||||
}
|
||||
}) {
|
||||
Text(translation["button.import"] ?: "Import")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportDialog = false }) {
|
||||
Text(translation["button.cancel"])
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = translation["friend_notes_description"],
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
val notes = context.database.getAllScopeNotes()
|
||||
if (notes.isEmpty()) {
|
||||
context.shortToast(translation["friend_notes_no_notes_to_backup"])
|
||||
return@runCatching
|
||||
}
|
||||
val json = context.gson.toJson(notes)
|
||||
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
|
||||
it.write(json.toByteArray())
|
||||
}
|
||||
context.shortToast(translation["friend_notes_backup_success"])
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to backup notes", it)
|
||||
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["backup_button"])
|
||||
}
|
||||
Button(onClick = {
|
||||
runCatching {
|
||||
activityLauncherHelper.openFile("application/json") { uri ->
|
||||
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
|
||||
val json = it.reader().readText()
|
||||
val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
|
||||
context.database.setAllScopeNotes(notes)
|
||||
context.shortToast(translation["friend_notes_restore_success"])
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to restore notes", it)
|
||||
context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: "")))
|
||||
}
|
||||
},
|
||||
colors = sharedButtonColors,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(text = translation["restore_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)
|
||||
) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
AestheticDropdownField(
|
||||
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
|
||||
expanded = expanded,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
onClick = { expanded = true }
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType ->
|
||||
DropdownMenuItem(onClick = {
|
||||
expanded = false
|
||||
selectedFileType = fileType
|
||||
}, text = {
|
||||
Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching {
|
||||
context.coroutineScope.launch {
|
||||
selectedFileType.resolve(context.androidContext).delete()
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to clear file", it)
|
||||
context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: "")))
|
||||
}.onSuccess {
|
||||
context.shortToast(translation["success_toast"])
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.1f),
|
||||
contentColor = Color.White
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
PremiumPreferenceToggle(
|
||||
context.sharedPreferences,
|
||||
key = "test_mode",
|
||||
text = translation["test_mode_label"],
|
||||
defaultValue = true
|
||||
)
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AestheticDropdownField(
|
||||
value: String,
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(Color.White.copy(alpha = 0.06f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = value, color = Color.White)
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
@@ -58,6 +53,8 @@ import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
class RetroGameScreen : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.retro_flight") }
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
val gridWidth = 120
|
||||
val gridHeight = 160
|
||||
@@ -194,6 +191,8 @@ class RetroGameScreen : Routes.Route() {
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
|
||||
24.dp
|
||||
|
||||
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -205,10 +204,50 @@ class RetroGameScreen : Routes.Route() {
|
||||
.padding(bottom = bottomPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
if (isAphelion) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: "Retro Flight",
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
|
||||
shape = shape,
|
||||
color = Color.White.copy(alpha = 0.07f),
|
||||
border = BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary.copy(alpha = 0.55f), PurrfectPalette.glowSecondary.copy(alpha = 0.35f)))),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
IconButton(onClick = { routes.navController.popBackStack() }, modifier = Modifier.size(42.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = translation["title"] ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -243,7 +282,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
Text(
|
||||
text = "GAME OVER",
|
||||
text = translation["game_over_label"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -258,7 +297,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
) {
|
||||
Text("RESTART", fontFamily = pixelFont, fontSize = 12.sp)
|
||||
Text(translation["restart_button"], fontFamily = pixelFont, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,7 +332,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "LEFT",
|
||||
text = translation["left_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
@@ -324,7 +363,7 @@ class RetroGameScreen : Routes.Route() {
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "RIGHT",
|
||||
text = translation["right_button"],
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = pixelFont,
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.location
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
|
||||
|
||||
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
|
||||
|
||||
alertDialogs.DefaultDialogCard {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
val fieldColors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.08f),
|
||||
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedLabelColor = PurrfectPalette.textSecondary,
|
||||
unfocusedLabelColor = PurrfectPalette.textSecondary,
|
||||
cursorColor = PurrfectPalette.glowSecondary,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 12.dp
|
||||
) {
|
||||
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) }
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) }
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) }
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.94f)
|
||||
)
|
||||
),
|
||||
RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
|
||||
Text(
|
||||
text = translation["save_coordinates_dialog_title"],
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
value = savedName,
|
||||
onValueChange = { savedName = it },
|
||||
label = { Text(translation["saved_name_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLatitude,
|
||||
onValueChange = { savedLatitude = it },
|
||||
label = { Text(translation["latitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = savedLongitude,
|
||||
onValueChange = { savedLongitude = it },
|
||||
label = { Text(translation["longitude_dialog_hint"]) },
|
||||
colors = fieldColors,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
Button(
|
||||
onClick = {
|
||||
confirm(LocationCoordinates().apply {
|
||||
this.name = savedName.text
|
||||
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
|
||||
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
|
||||
})
|
||||
},
|
||||
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
) {
|
||||
Text(translation["save_dialog_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -127,7 +126,11 @@ class BetterLocationRoot : Routes.Route() {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "Lat ${friendLocation.latitude.toFloat()}, Lng ${friendLocation.longitude.toFloat()}",
|
||||
text = translation.format(
|
||||
"spoofed_coordinates_title",
|
||||
"latitude" to friendLocation.latitude.toFloat().toString(),
|
||||
"longitude" to friendLocation.longitude.toFloat().toString()
|
||||
),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
@@ -217,16 +220,21 @@ class BetterLocationRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemedEditLocationButton(onClick: () -> Unit) {
|
||||
private fun CoordinateActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
accent: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilledIconButton(
|
||||
modifier = Modifier.size(40.dp),
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(42.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = if (isSystemInDarkTheme()) Color.White else Color(0xFF151A1A),
|
||||
),
|
||||
onClick = onClick
|
||||
containerColor = accent.copy(alpha = 0.22f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_location_button_description"])
|
||||
Icon(icon, contentDescription = description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +242,8 @@ class BetterLocationRoot : Routes.Route() {
|
||||
val coordinatesProperty = remember {
|
||||
context.config.root.global.betterLocation.getPropertyPair("coordinates")
|
||||
}
|
||||
val providerProperty = remember { context.config.root.global.betterLocation.getPropertyPair("location_search_provider") }
|
||||
val apiKeyProperty = remember { context.config.root.global.betterLocation.getPropertyPair("google_maps_api_key") }
|
||||
|
||||
val updateDispatcher = rememberAsyncUpdateDispatcher()
|
||||
val savedCoordinates = rememberAsyncMutableStateList(
|
||||
@@ -245,10 +255,17 @@ class BetterLocationRoot : Routes.Route() {
|
||||
var showMap by remember { mutableStateOf(false) }
|
||||
var addSavedCoordinateDialog by remember { mutableStateOf(false) }
|
||||
var showTeleportDialog by remember { mutableStateOf(false) }
|
||||
var showProviderDialog by remember { mutableStateOf(false) }
|
||||
var showApiKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val marker = remember { mutableStateOf<Marker?>(null) }
|
||||
val mapView = remember { mutableStateOf<MapView?>(null) }
|
||||
var spoofedCoordinates by remember(showTeleportDialog, showMap) { mutableStateOf(coordinatesProperty.value.get() as? Pair<*, *>) }
|
||||
var spoofedCoordinates by remember(showTeleportDialog, showMap) {
|
||||
mutableStateOf(
|
||||
(coordinatesProperty.value.getNullable() as? Pair<*, *>)
|
||||
?: (0.0 to 0.0)
|
||||
)
|
||||
}
|
||||
|
||||
fun addSavedCoordinate(id: Int?, locationCoordinates: LocationCoordinates, onSuccess: suspend (id: Int) -> Unit = {}) {
|
||||
context.coroutineScope.launch {
|
||||
@@ -271,6 +288,36 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
|
||||
var currentProvider by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm")
|
||||
}
|
||||
var currentApiKey by remember {
|
||||
mutableStateOf(context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "")
|
||||
}
|
||||
|
||||
if (showProviderDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showProviderDialog = false
|
||||
context.config.writeConfig()
|
||||
currentProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm"
|
||||
}) {
|
||||
alertDialogs.UniqueSelectionDialog(providerProperty)
|
||||
}
|
||||
}
|
||||
if (showApiKeyDialog) {
|
||||
me.eternal.purrfectsnap.ui.util.Dialog(onDismissRequest = {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}) {
|
||||
alertDialogs.KeyboardInputDialog(apiKeyProperty) {
|
||||
showApiKeyDialog = false
|
||||
context.config.writeConfig()
|
||||
currentApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -337,9 +384,16 @@ class BetterLocationRoot : Routes.Route() {
|
||||
)
|
||||
) {
|
||||
Box(modifier = Modifier.background(PurrfectPalette.cardOverlay)) {
|
||||
alertDialogs.ChooseLocationDialog(property = coordinatesProperty, marker, mapView, saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}) {
|
||||
alertDialogs.ChooseLocationDialog(
|
||||
property = coordinatesProperty,
|
||||
marker = marker,
|
||||
mapView = mapView,
|
||||
locationSearchProvider = context.config.root.global.betterLocation.locationSearchProvider.getNullable() ?: "osm",
|
||||
googleMapsApiKey = context.config.root.global.betterLocation.googleMapsApiKey.getNullable() ?: "",
|
||||
saveCoordinates = {
|
||||
addSavedCoordinateDialog = true
|
||||
}
|
||||
) {
|
||||
showMap = false
|
||||
context.config.writeConfig()
|
||||
}
|
||||
@@ -395,6 +449,42 @@ class BetterLocationRoot : Routes.Route() {
|
||||
) {
|
||||
context.config.root.global.betterLocation.suspendLocationUpdates.set(it)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigSelector(text: String, value: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = value,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfigInput(text: String, value: String, onClick: () -> Unit) {
|
||||
ConfigSelector(text, if (value.isNotEmpty()) "********" else translation["options.empty"], onClick)
|
||||
}
|
||||
|
||||
ConfigSelector(
|
||||
text = translation["location_search_provider_title"],
|
||||
value = translation["option_$currentProvider"]
|
||||
) { showProviderDialog = true }
|
||||
|
||||
if (currentProvider == "google_maps") {
|
||||
ConfigInput(
|
||||
text = translation["google_maps_api_key_title"],
|
||||
value = currentApiKey
|
||||
) { showApiKeyDialog = true }
|
||||
}
|
||||
}
|
||||
item {
|
||||
GlassPanel(
|
||||
@@ -588,15 +678,19 @@ class BetterLocationRoot : Routes.Route() {
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.Edit,
|
||||
description = translation["edit_icon_description"],
|
||||
accent = PurrfectPalette.glowPrimary
|
||||
) {
|
||||
showEditDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = translation["edit_icon_description"])
|
||||
}
|
||||
FilledIconButton(onClick = {
|
||||
CoordinateActionButton(
|
||||
icon = Icons.Default.DeleteOutline,
|
||||
description = translation["delete_icon_description"],
|
||||
accent = PurrfectPalette.glowSecondary
|
||||
) {
|
||||
showDeleteDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.DeleteOutline, contentDescription = translation["delete_icon_description"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -292,7 +292,7 @@ class ManageScriptReposSection : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,8 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
Button(
|
||||
onClick = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md"
|
||||
"https://github.com/particle-box/PurrfectSnap/blob/dev/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptRepos.md",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -205,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
) {
|
||||
item {
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = translation["loading"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
|
||||
Box(
|
||||
@@ -310,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Text(
|
||||
text = translation.format("version", "version" to (entry.version ?: "N/A")),
|
||||
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.*
|
||||
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
@@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.storage.isScriptEnabled
|
||||
import me.eternal.purrfectsnap.storage.setScriptEnabled
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.chooseFolder
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
|
||||
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
|
||||
|
||||
class ScriptingRootSection : Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.scripting") }
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
private var selectedTab by mutableStateOf(0)
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
|
||||
internal var selectedTab by mutableStateOf(0)
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
}
|
||||
|
||||
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
|
||||
return try {
|
||||
val installedScripts = context.scriptManager.getSyncedModules()
|
||||
installedScripts.any { module ->
|
||||
@@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
|
||||
context.coroutineScope.launch {
|
||||
if (isScriptInstalledByUrl(scriptUrl)) {
|
||||
context.shortToast(translation["script_already_installed"])
|
||||
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportRemoteScript(
|
||||
internal fun ImportRemoteScript(
|
||||
dismiss: () -> Unit
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
@@ -140,7 +142,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
return@launch
|
||||
}.onFailure {
|
||||
context.log.error("Failed to import script", it)
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModuleActions(
|
||||
internal fun ModuleActions(
|
||||
script: ModuleInfo,
|
||||
canUpdate: Boolean,
|
||||
dismiss: () -> Unit
|
||||
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModuleItem(script: ModuleInfo) {
|
||||
internal fun ModuleItem(script: ModuleInfo) {
|
||||
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
|
||||
context.database.isScriptEnabled(script.name)
|
||||
}
|
||||
@@ -430,8 +432,8 @@ class ScriptingRootSection : Routes.Route() {
|
||||
override val floatingActionButton: @Composable () -> Unit = {}
|
||||
|
||||
@Composable
|
||||
private fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
|
||||
internal fun SelectFolderButton(onClick: () -> Unit) {
|
||||
val label = translation["select_folder_button"]
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -439,13 +441,13 @@ class ScriptingRootSection : Routes.Route() {
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(68.dp),
|
||||
modifier = Modifier.size(78.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp,
|
||||
shadowElevation = 14.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
1.5.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
|
||||
@@ -456,17 +458,18 @@ class ScriptingRootSection : Routes.Route() {
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(6.dp)
|
||||
.size(66.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.radialGradient(
|
||||
colors = listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
|
||||
Color.Transparent
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -474,7 +477,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = label,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -482,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScriptSettings(script: ModuleInfo) {
|
||||
internal fun ScriptSettings(script: ModuleInfo) {
|
||||
val settingsInterface = remember {
|
||||
val module =
|
||||
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
|
||||
@@ -499,70 +502,23 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) showToast = true else scriptingFolder?.let { context.androidContext.openLink(it.uri.toString()) }
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = { context.androidContext.openLink("https://github.com/SnapEnhance/scripting-docs") },
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@ScriptingRootSection.ScriptingScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstalledTabContent(
|
||||
internal fun InstalledTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val scriptModules by rememberAsyncMutableState(
|
||||
@@ -613,7 +569,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(260.dp),
|
||||
.heightIn(min = 260.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
@@ -729,7 +685,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
title = context.translation["manager.dialogs.scripting_warning.title"],
|
||||
text = context.translation["manager.dialogs.scripting_warning.content"],
|
||||
icon = Icons.Default.Warning,
|
||||
confirmButtonText = translation["button.ok"] ?: "OK",
|
||||
confirmButtonText = translation["button.ok"],
|
||||
onConfirm = { if (timeout == 0) scriptingWarning = false },
|
||||
loading = timeout > 0,
|
||||
showCloseButton = false,
|
||||
@@ -785,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatalogTabContent(
|
||||
internal fun CatalogTabContent(
|
||||
scriptingFolder: DocumentFile?
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -818,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingHeader(
|
||||
internal fun ScriptingHeader(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit,
|
||||
@@ -861,7 +817,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.scripts"] ?: "Scripts",
|
||||
text = translation["manager.routes.scripts"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -896,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScriptingTabSwitcher(
|
||||
internal fun ScriptingTabSwitcher(
|
||||
titles: List<String>,
|
||||
selectedTab: Int,
|
||||
onTabSelected: (Int) -> Unit
|
||||
@@ -943,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
|
||||
|
||||
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
@@ -375,8 +389,7 @@ class AddFriendDialog(
|
||||
enabled = !allFriendsSelected
|
||||
) {
|
||||
Text(
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
|
||||
?: "Select All",
|
||||
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
|
||||
color = if (allFriendsSelected) {
|
||||
Color.White.copy(alpha = 0.45f)
|
||||
} else {
|
||||
|
||||
@@ -192,7 +192,7 @@ class LoggedStories : Routes.Route() {
|
||||
content = context.imageLoader.diskCache?.openSnapshot(story.url)?.use {
|
||||
it.data.toFile().absolutePath
|
||||
} ?: run {
|
||||
context.shortToast("Failed to get file")
|
||||
context.shortToast(translation["failed_to_get_file"])
|
||||
return@Button
|
||||
},
|
||||
type = DownloadMediaType.LOCAL_MEDIA,
|
||||
|
||||
@@ -91,7 +91,7 @@ class ManageScope: Routes.Route() {
|
||||
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
|
||||
text = "",
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
confirmButtonText = translation["delete_button"] ?: "Delete",
|
||||
confirmButtonText = translation["delete_button"],
|
||||
dismissButtonText = context.translation["button.cancel"],
|
||||
onDismiss = { deleteConfirmDialog = false },
|
||||
onConfirm = {
|
||||
@@ -109,7 +109,7 @@ class ManageScope: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: "Manage",
|
||||
title = titleText ?: translation["manage_scope_title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { deleteConfirmDialog = true }) {
|
||||
|
||||
@@ -63,7 +63,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
|
||||
class MessagingPreview: Routes.Route() {
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview.messaging_preview") }
|
||||
override val translation by lazy { context.translation.getCategory("manager.sections.social.messaging_preview") }
|
||||
private lateinit var coroutineScope: CoroutineScope
|
||||
private lateinit var previewScrollState: LazyListState
|
||||
|
||||
@@ -79,21 +79,6 @@ class MessagingPreview: Routes.Route() {
|
||||
else selectedMessages.add(messageId)
|
||||
}
|
||||
|
||||
private fun tr(key: String, fallback: String? = null): String {
|
||||
fun normalizeCandidate(candidate: String?): String? {
|
||||
if (candidate.isNullOrBlank()) return null
|
||||
if (candidate == key) return null
|
||||
if (candidate.endsWith(".$key")) return null
|
||||
return candidate
|
||||
}
|
||||
|
||||
return normalizeCandidate(translation.getOrNull(key))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.sections.social.messaging_preview.$key"))
|
||||
?: normalizeCandidate(context.translation.getOrNull("manager.social.messaging_preview.$key"))
|
||||
?: fallback
|
||||
?: key
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionsSheetItem(
|
||||
title: String,
|
||||
@@ -338,20 +323,20 @@ class MessagingPreview: Routes.Route() {
|
||||
|
||||
val senderDisplayName by rememberAsyncMutableState<String?>(null, keys = arrayOf(senderId, myUserId, scope.key, scopeId, friendDisplayName)) {
|
||||
when {
|
||||
senderId == null -> "Unknown"
|
||||
senderId == myUserId -> "You"
|
||||
senderId == null -> translation["sender_unknown"]
|
||||
senderId == myUserId -> translation["sender_you"]
|
||||
scope == SocialScope.FRIEND -> friendDisplayName
|
||||
?: context.database.getFriendInfo(scopeId)?.displayName
|
||||
?: context.database.getFriendInfo(scopeId)?.mutableUsername
|
||||
?: "Friend"
|
||||
?: translation["sender_friend"]
|
||||
else -> context.database.getFriendInfo(senderId)?.displayName
|
||||
?: context.database.getFriendInfo(senderId)?.mutableUsername
|
||||
?: "Unknown"
|
||||
?: translation["sender_unknown"]
|
||||
}
|
||||
}
|
||||
|
||||
val contentTypeLabel = remember(contentType) {
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: "Unknown"
|
||||
contentType?.let { contentTypeTranslation.getOrNull(it.name) ?: it.name } ?: translation["sender_unknown"]
|
||||
}
|
||||
val bodyText = remember(message.contentType) { messageReader.getString(2, 1)?.trim().orEmpty() }
|
||||
|
||||
@@ -408,7 +393,7 @@ class MessagingPreview: Routes.Route() {
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = senderDisplayName ?: "Unknown",
|
||||
text = senderDisplayName ?: translation["sender_unknown"],
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
@@ -487,7 +472,7 @@ class MessagingPreview: Routes.Route() {
|
||||
.padding(40.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(tr("no_message_hint", "No messages"), color = PurrfectPalette.textSecondary)
|
||||
Text(translation["no_message_hint"], color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
@@ -564,7 +549,7 @@ class MessagingPreview: Routes.Route() {
|
||||
translation.format("processed_message_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: translation.getOrNull("processed_messages_toast")?.let {
|
||||
translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue} messages"
|
||||
} ?: translation.format("processed_messages_toast", "count" to processMessageCount.intValue.toString())
|
||||
context.longToast(toastText)
|
||||
}
|
||||
}
|
||||
@@ -579,7 +564,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -640,7 +625,7 @@ class MessagingPreview: Routes.Route() {
|
||||
) {
|
||||
val processedText = translation.getOrNull("processed_messages_text")?.let {
|
||||
translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
} ?: "Processed ${processMessageCount.intValue}"
|
||||
} ?: translation.format("processed_messages_text", "count" to processMessageCount.intValue.toString())
|
||||
Text(processedText)
|
||||
if (activeTask?.hasFixedGoal() == true) {
|
||||
LinearProgressIndicator(
|
||||
@@ -679,7 +664,7 @@ class MessagingPreview: Routes.Route() {
|
||||
}.onFailure {
|
||||
context.log.error("Failed to fetch messages", it)
|
||||
context.shortToast(
|
||||
translation.getOrNull("message_fetch_failed") ?: "Failed to fetch messages"
|
||||
translation.getOrNull("message_fetch_failed") ?: translation["message_fetch_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -710,7 +695,7 @@ class MessagingPreview: Routes.Route() {
|
||||
fetchNewMessages()
|
||||
}.onFailure {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_init_failed") ?: "Failed to initialize messaging bridge"
|
||||
translation.getOrNull("bridge_init_failed") ?: translation["bridge_init_failed"]
|
||||
)
|
||||
context.log.error("Failed to initialize messaging bridge", it)
|
||||
}
|
||||
@@ -751,11 +736,11 @@ class MessagingPreview: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = titleText ?: translation["title"] ?: "Preview",
|
||||
title = titleText ?: translation["title"],
|
||||
subtitle = if (selectedMessages.isNotEmpty()) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
tr("subtitle", "Hold to select")
|
||||
translation["subtitle"]
|
||||
.substringBefore("•")
|
||||
.substringBefore("·")
|
||||
.substringBefore("|")
|
||||
@@ -798,7 +783,7 @@ class MessagingPreview: Routes.Route() {
|
||||
Text(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge",
|
||||
?: translation["bridge_connection_error"],
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color.White
|
||||
)
|
||||
@@ -831,7 +816,7 @@ class MessagingPreview: Routes.Route() {
|
||||
val selectionSubtitle = if (hasSelection) {
|
||||
"${selectedMessages.size} selected"
|
||||
} else {
|
||||
"Choose message types"
|
||||
translation["choose_message_types_subtitle"]
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -862,7 +847,7 @@ class MessagingPreview: Routes.Route() {
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
text = tr("actions_title", "Conversation Actions"),
|
||||
text = translation["actions_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp,
|
||||
@@ -886,31 +871,31 @@ class MessagingPreview: Routes.Route() {
|
||||
val deleteKey = if (hasSelection) "delete_selection_option" else "delete_all_option"
|
||||
|
||||
ActionsSheetItem(
|
||||
title = tr(saveKey, if (hasSelection) "Save Selection" else "Save All"),
|
||||
subtitle = if (hasSelection) "Save selected messages" else "Save by content type",
|
||||
title = translation[saveKey],
|
||||
subtitle = if (hasSelection) translation["save_selected_messages_subtitle"] else translation["save_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkAdded
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.SAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(unsaveKey, if (hasSelection) "Unsave Selection" else "Unsave All"),
|
||||
subtitle = if (hasSelection) "Unsave selected messages" else "Unsave by content type",
|
||||
title = translation[unsaveKey],
|
||||
subtitle = if (hasSelection) translation["unsave_selected_messages_subtitle"] else translation["unsave_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.BookmarkBorder
|
||||
) {
|
||||
launchMessagingTask(MessagingTaskType.UNSAVE)
|
||||
if (hasSelection) runCurrentTask() else selectConstraintsDialog = true
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(markKey, if (hasSelection) "Mark selected as seen" else "Mark all as seen"),
|
||||
subtitle = "Marks snaps as seen",
|
||||
title = translation[markKey],
|
||||
subtitle = translation["mark_as_seen_subtitle"],
|
||||
icon = Icons.Rounded.RemoveRedEye
|
||||
) {
|
||||
if (messagingBridge == null) {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
@@ -924,8 +909,8 @@ class MessagingPreview: Routes.Route() {
|
||||
runCurrentTask()
|
||||
}
|
||||
ActionsSheetItem(
|
||||
title = tr(deleteKey, if (hasSelection) "Delete Selection" else "Delete All"),
|
||||
subtitle = if (hasSelection) "Delete selected messages" else "Delete by content type",
|
||||
title = translation[deleteKey],
|
||||
subtitle = if (hasSelection) translation["delete_selected_messages_subtitle"] else translation["delete_by_content_type_subtitle"],
|
||||
icon = Icons.Rounded.DeleteForever,
|
||||
danger = true
|
||||
) {
|
||||
@@ -933,7 +918,7 @@ class MessagingPreview: Routes.Route() {
|
||||
context.longToast(
|
||||
translation.getOrNull("bridge_connection_error")
|
||||
?: translation.getOrNull("bridge_connection_failed")
|
||||
?: "Failed to connect to bridge"
|
||||
?: translation["bridge_connection_error"]
|
||||
)
|
||||
return@ActionsSheetItem
|
||||
}
|
||||
|
||||
@@ -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,138 +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"] ?: "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 = context.translation["close_button_description"]
|
||||
?: "Clear search",
|
||||
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?,
|
||||
@@ -479,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,
|
||||
@@ -521,7 +419,7 @@ class SocialRootSection : Routes.Route() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["manager.routes.social"] ?: "Social",
|
||||
text = translation["manager.routes.social"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 18.sp
|
||||
@@ -532,12 +430,12 @@ class SocialRootSection : Routes.Route() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StatPill(label = "Friends", value = friendCount)
|
||||
StatPill(label = "Groups", value = groupCount)
|
||||
StatPill(label = translation["friends_tab"], value = friendCount)
|
||||
StatPill(label = translation["groups_tab"], value = groupCount)
|
||||
IconButton(onClick = onSearchToggle) {
|
||||
Icon(
|
||||
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
|
||||
contentDescription = if (searchActive) "Close search" else "Search",
|
||||
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
@@ -553,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
|
||||
@@ -599,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"]
|
||||
@@ -626,13 +524,13 @@ class SocialRootSection : Routes.Route() {
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
text = title ?: "",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
|
||||
text = translation["social_empty_hint"] ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -641,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),
|
||||
@@ -670,3 +568,7 @@ class SocialRootSection : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextIndent
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
|
||||
@Composable
|
||||
fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
|
||||
val avenirNext = remember {
|
||||
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val aboutStory = remember { translation["about_story"]?.trim() ?: "" }
|
||||
val horizontalPadding = 24.dp
|
||||
val bottomPadding = routes.bottomPadding
|
||||
val tapSource = remember { MutableInteractionSource() }
|
||||
val tapTimeoutMs = 1500L
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
|
||||
|
||||
LaunchedEffect(scrollState.value) {
|
||||
routes.navigation?.globalScrollOffset = scrollState.value
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(bottom = bottomPadding + 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(controlsHeight))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth().background(PurrfectPalette.panelGradient)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = translation["about_title"] ?: "About",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontFamily = avenirNext,
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = tapSource,
|
||||
indication = null,
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastTapTime.longValue > tapTimeoutMs) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
tapCount.intValue += 1
|
||||
lastTapTime.longValue = now
|
||||
if (tapCount.intValue >= 5) {
|
||||
tapCount.intValue = 0
|
||||
routes.retroGame.navigate()
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation["about_tagline"] ?: "",
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = translation["about_lead_developers_title"] ?: "Lead Developers",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LITERAL OUR STORY VERBATIM RESTORATION (Justified & Indented)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PurrfectMarqueeText(
|
||||
text = translation["about_story_title"] ?: "About Us",
|
||||
color = Color.White,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(0.7f).padding(vertical = 4.dp),
|
||||
thickness = 1.2.dp,
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = aboutStory,
|
||||
fontSize = 15.sp,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
textAlign = TextAlign.Justify,
|
||||
style = LocalTextStyle.current.copy(
|
||||
lineHeight = 22.sp,
|
||||
textIndent = TextIndent(firstLine = 24.sp)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = translation["about_thanks_title"] ?: "Special Thanks", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"] ?: "") }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_github), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["github_button"] ?: "GitHub", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"] ?: "") }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = translation["telegram_button"] ?: "Telegram", maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"] ?: "About Us",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = scrollState.value,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
|
||||
import me.eternal.purrfectsnap.common.config.ConfigContainer
|
||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
||||
import me.eternal.purrfectsnap.common.config.toPropertyPair
|
||||
|
||||
@Composable
|
||||
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
|
||||
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
when (currentDestination?.route) {
|
||||
FEATURE_CONTAINER_ROUTE -> {
|
||||
val containerName = navBackStackEntry?.arguments?.getString("name")!!
|
||||
val propertyPair = allContainers[containerName]!!
|
||||
Container(
|
||||
configContainer = propertyPair.value.get() as ConfigContainer,
|
||||
stateKey = "${routeInfo.id}:container:$containerName",
|
||||
sectionTitle = context.translation[propertyPair.key.propertyName()],
|
||||
sectionSubtitle = context.translation[propertyPair.key.propertyDescription()],
|
||||
onBack = { routes.navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
SEARCH_FEATURE_ROUTE -> {
|
||||
val keyword = navBackStackEntry?.arguments?.getString("keyword").orEmpty()
|
||||
val properties = allProperties.filter {
|
||||
isSearchVisibleProperty(it.key) && (
|
||||
it.key.name.contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
|
||||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
|
||||
)
|
||||
}.map { (it.key to it.value).toPropertyPair() }
|
||||
|
||||
PropertiesView(
|
||||
properties = properties,
|
||||
stateKey = "${routeInfo.id}:search:$keyword",
|
||||
isSearchResults = true,
|
||||
searchKeyword = keyword,
|
||||
enableGlobalSearch = true,
|
||||
onBack = { navigateToMainRoot() }
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Container(
|
||||
configContainer = context.config.root,
|
||||
stateKey = "${routeInfo.id}:container:root"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
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 showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var fullChangelogLoading by remember { mutableStateOf(false) }
|
||||
var fullChangelogError by remember { mutableStateOf<String?>(null) }
|
||||
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var announcementsLoading by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
LaunchedEffect(scrollState.value) { routes.navigation?.globalScrollOffset = scrollState.value }
|
||||
|
||||
val handleUpdateAction: () -> Unit = {
|
||||
latestUpdate?.let { latest ->
|
||||
val abiName = android.os.Build.SUPPORTED_ABIS.firstNotNullOfOrNull {
|
||||
when (it) { "arm64-v8a" -> "arm64"; "armeabi-v7a" -> "armv7"; else -> null }
|
||||
}
|
||||
if (latest.workflowId != null) {
|
||||
if (abiName != null) {
|
||||
val artifactName = "purrfectsnap-${if (abiName == "arm64") "armv8" else "armv7"}-debug"
|
||||
UpdateDownloader.downloadAndInstall(context, "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip", "$artifactName.zip", coroutineScope)
|
||||
}
|
||||
} else {
|
||||
abiName?.let { arch -> latest.assetDownloads[arch] }?.let { url ->
|
||||
UpdateDownloader.downloadAndInstall(context, url, url.substringAfterLast('/'), coroutineScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadChangelog() {
|
||||
val targetVersion = latestUpdate?.versionName ?: BuildConfig.VERSION_NAME
|
||||
if (changelogVersion == targetVersion && changelogText != null) return
|
||||
changelogLoading = true
|
||||
changelogError = null
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val url = 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 } }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFullChangelog() {
|
||||
if (fullChangelogText != null) return
|
||||
fullChangelogLoading = true
|
||||
fullChangelogError = 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")
|
||||
body.trim()
|
||||
}
|
||||
}.onSuccess { text ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogText = text
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}.onFailure { e ->
|
||||
withContext(Dispatchers.Main) {
|
||||
fullChangelogError = e.message ?: "Failed to fetch"
|
||||
fullChangelogLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val borderPath = remember { Path() }
|
||||
val uPath = remember { Path() }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(HomeRootSection.pageBackgroundGradient)) {
|
||||
|
||||
val focusFactor by remember(scrollState.value) {
|
||||
derivedStateOf { (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
|
||||
}
|
||||
val stickyBrandingAlpha by remember(scrollState.value) {
|
||||
derivedStateOf { ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f) }
|
||||
}
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val headerHeight = lerp(54.dp, 56.dp, focusFactor)
|
||||
val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
|
||||
val topCorners = lerp(26.dp, 0.dp, focusFactor)
|
||||
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) {
|
||||
val refractiveColor = remember { Color(0xFF241F52) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = containerTopPadding)
|
||||
.height(internalTopPadding + headerHeight + 32.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
|
||||
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
|
||||
1.0f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = containerTopPadding)
|
||||
.headerHeightTracker { controlsHeight = it }
|
||||
.drawBehind {
|
||||
val strokeWidth = 1.dp.toPx()
|
||||
val brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = focusFactor * 0.6f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = focusFactor * 0.4f)
|
||||
)
|
||||
)
|
||||
val tr = topCorners.toPx()
|
||||
val br = bottomCorners.toPx()
|
||||
if (focusFactor > 0.9f) {
|
||||
uPath.reset()
|
||||
uPath.apply {
|
||||
moveTo(0f, 0f)
|
||||
lineTo(0f, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 180f, -90f, false)
|
||||
lineTo(size.width - br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 90f, -90f, false)
|
||||
lineTo(size.width, 0f)
|
||||
}
|
||||
drawPath(uPath, brush, style = Stroke(strokeWidth))
|
||||
} else if (focusFactor > 0.01f) {
|
||||
borderPath.reset()
|
||||
borderPath.apply {
|
||||
moveTo(tr, 0f)
|
||||
lineTo(size.width - tr, 0f)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * tr, 0f, size.width, 2 * tr), 270f, 90f, false)
|
||||
lineTo(size.width, size.height - br)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2 * br, size.height - 2 * br, size.width, size.height), 0f, 90f, false)
|
||||
lineTo(br, size.height)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2 * br, 2 * br, size.height), 90f, 90f, false)
|
||||
lineTo(0f, tr)
|
||||
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2 * tr, 2 * tr), 180f, 90f, false)
|
||||
}
|
||||
drawPath(borderPath, brush, style = Stroke(strokeWidth))
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = bottomCorners, bottomEnd = bottomCorners),
|
||||
color = Color(0xFF1B152E).copy(alpha = focusFactor * 0.95f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = internalTopPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(headerHeight)
|
||||
) {
|
||||
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,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Notifications, label = null,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation["announcements_button_description"],
|
||||
haptic = haptic
|
||||
) { showAnnouncementsDialog = true; loadAnnouncements() }
|
||||
AphelionTopBarActionChip(
|
||||
icon = Icons.Filled.Description, label = null,
|
||||
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
|
||||
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
|
||||
haptic = haptic
|
||||
) { showFullChangelogDialog = true; loadFullChangelog() }
|
||||
}
|
||||
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 },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
|
||||
else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "", icon = Icons.Filled.Info,
|
||||
confirmButtonText = translation["changelog_dialog_update_button"] ?: "Update",
|
||||
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
|
||||
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
|
||||
onDismiss = { showChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (changelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (changelogError != null) Text(changelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(changelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showFullChangelogDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showFullChangelogDialog = false },
|
||||
title = translation["changelog_dialog_title"] ?: "Changelog",
|
||||
text = "",
|
||||
icon = Icons.Filled.Description,
|
||||
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
|
||||
onConfirm = { showFullChangelogDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
|
||||
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
|
||||
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showQuickActionsMenu) {
|
||||
QuickActionsDialog(
|
||||
quickActions = cards,
|
||||
selectedQuickActions = selectedTiles,
|
||||
onDismiss = { showQuickActionsMenu = false },
|
||||
onSave = { newList ->
|
||||
val removed = selectedTiles.filter { it !in newList }
|
||||
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
|
||||
selectedTiles.clear(); selectedTiles.addAll(newList)
|
||||
context.coroutineScope.launch { context.database.setQuickTiles(selectedTiles) }
|
||||
showQuickActionsMenu = false
|
||||
},
|
||||
translation = translation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
val composeContext = LocalContext.current
|
||||
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
isRefreshing = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
val readerResult = runCatching {
|
||||
context.log.newReader { line ->
|
||||
if (shouldHideLog(line)) return@newReader
|
||||
coroutineScope.launch(Dispatchers.Main) {
|
||||
visibleLogs.add(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
readerResult.onFailure {
|
||||
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
|
||||
}
|
||||
readerResult.getOrNull()?.let { reader ->
|
||||
logReader = reader
|
||||
val filteredLogs = (0 until reader.lineCount).mapNotNull { index ->
|
||||
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
visibleLogs.clear()
|
||||
visibleLogs.addAll(filteredLogs)
|
||||
if (visibleLogs.isNotEmpty()) {
|
||||
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
refreshLogs()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
refreshLogs()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
var showDropDown by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
if (visibleLogs.isEmpty() && logReader != null) {
|
||||
EmptyLogsState()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = logListState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = controlsHeight,
|
||||
bottom = routes.bottomPadding + 12.dp
|
||||
)
|
||||
) {
|
||||
items(visibleLogs, key = { it.hashCode() }) { line ->
|
||||
LogEntryCard(line = line, composeContext = composeContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = context.translation["manager.routes.home_logs"] ?: "Logs",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { refreshLogs() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showDropDown = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showDropDown,
|
||||
onDismissRequest = { showDropDown = false },
|
||||
offset = DpOffset(0.dp, 8.dp),
|
||||
containerColor = Color(0xFF161821),
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 12.dp,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
clearLogsAndReload()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
|
||||
text = { Text(translation["clear_logs_button"] ?: "Clear", color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowPrimary
|
||||
)
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
exportLogs()
|
||||
showDropDown = false
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
|
||||
text = { Text(translation["export_logs_button"] ?: "Export", color = Color.White) },
|
||||
colors = MenuDefaults.itemColors(
|
||||
textColor = Color.White,
|
||||
leadingIconColor = PurrfectPalette.glowSecondary
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ScriptingRootSection.AphelionScriptingScreen(nav: NavBackStackEntry) {
|
||||
val scriptingFolder by rememberAsyncMutableState(
|
||||
defaultValue = null,
|
||||
updateDispatcher = reloadDispatcher
|
||||
) { context.scriptManager.getScriptsFolder() }
|
||||
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var showToast by remember { mutableStateOf(false) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(scriptingFolder) {
|
||||
if (scriptingFolder == null && selectedTab != 0) {
|
||||
selectedTab = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showImportDialog) {
|
||||
ImportRemoteScript { showImportDialog = false }
|
||||
}
|
||||
if (showToast) {
|
||||
LaunchedEffect(showToast) {
|
||||
context.shortToast(translation["select_scripts_folder_toast"])
|
||||
showToast = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
ScriptingHeader(
|
||||
titles = tabTitles,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { index ->
|
||||
if (index == 1 && scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
selectedTab = index
|
||||
}
|
||||
},
|
||||
onImport = {
|
||||
if (scriptingFolder == null) showToast = true else showImportDialog = true
|
||||
},
|
||||
onOpenFolder = {
|
||||
if (scriptingFolder == null) {
|
||||
showToast = true
|
||||
} else {
|
||||
scriptingFolder?.let {
|
||||
context.androidContext.openLink(
|
||||
it.uri.toString(),
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onManageRepos = { routes.manageScriptRepos.navigate() },
|
||||
onDocs = {
|
||||
context.androidContext.openLink(
|
||||
"https://github.com/SnapEnhance/scripting-docs",
|
||||
context.translation["toast_open_link_failed"]
|
||||
)
|
||||
},
|
||||
folderSelected = scriptingFolder != null
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
when (selectedTab) {
|
||||
0 -> InstalledTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
1 -> CatalogTabContent(
|
||||
scriptingFolder = scriptingFolder
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.storage.getAllScopeNotes
|
||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.AphelionHaptics
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.setup.Requirements
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.saveFile
|
||||
import me.eternal.purrfectsnap.ui.util.openFile
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.drawToBitmap
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val view = LocalView.current
|
||||
var switchCenter by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
var showResetSetupDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val sharedButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White.copy(alpha = 0.07f),
|
||||
contentColor = Color.White
|
||||
)
|
||||
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
|
||||
|
||||
val computedScrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt()
|
||||
else listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(computedScrollOffset) {
|
||||
routes.navigation?.globalScrollOffset = computedScrollOffset
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
if (showResetSetupDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showResetSetupDialog = false },
|
||||
title = translation["reset_setup_dialog_title"],
|
||||
text = translation["reset_setup_dialog_text"],
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = context.translation["button.positive"],
|
||||
dismissButtonText = context.translation["button.negative"],
|
||||
onConfirm = {
|
||||
showResetSetupDialog = false
|
||||
context.sharedPreferences.edit()
|
||||
.remove("setup_in_progress")
|
||||
.remove("setup_current_route")
|
||||
.remove("setup_skip_patch")
|
||||
.remove("setup_install_mode")
|
||||
.apply()
|
||||
context.config.reset()
|
||||
context.config.writeConfig()
|
||||
val intent = Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.androidContext.startActivity(intent)
|
||||
routes.navController.popBackStack()
|
||||
},
|
||||
onDismiss = { showResetSetupDialog = false },
|
||||
showCloseButton = false
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = routes.bottomPadding + 24.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.height(controlsHeight))
|
||||
}
|
||||
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// THEME SWITCHER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_theme_title"] ?: "UI Theme")
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["settings_ui_theme"] ?: "Aphelion Theme", fontSize = 14.sp, color = Color.White)
|
||||
val currentThemeId = context.config.root.global.uiSettings.managerTheme.get()
|
||||
var localThemeId by remember { mutableStateOf(currentThemeId) }
|
||||
|
||||
Switch(
|
||||
checked = localThemeId == "APHELION",
|
||||
onCheckedChange = { isAphelion ->
|
||||
val newId = if (isAphelion) "APHELION" else "LEGACY"
|
||||
localThemeId = newId // Update UI instantly
|
||||
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
// 1. Capture bitmap BEFORE theme change
|
||||
val bitmap = runCatching { view.drawToBitmap() }.getOrNull()
|
||||
|
||||
// 2. Request Reveal
|
||||
routes.navigation?.themeRevealState?.requestReveal(
|
||||
newThemeId = newId,
|
||||
originCenter = switchCenter,
|
||||
bitmap = bitmap
|
||||
)
|
||||
|
||||
// 3. Apply theme and persist
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(50)
|
||||
context.config.root.global.uiSettings.managerTheme.set(newId)
|
||||
|
||||
// Write to disk immediately on IO thread and finish
|
||||
val writeJob = launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
context.config.writeConfig()
|
||||
}
|
||||
writeJob.join() // Ensure it finishes its work before scope potentially closes
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(end = 26.dp)
|
||||
.onGloballyPositioned { coords ->
|
||||
val rootPos = coords.positionInRoot()
|
||||
switchCenter = androidx.compose.ui.geometry.Offset(
|
||||
x = rootPos.x + coords.size.width / 2f,
|
||||
y = rootPos.y + coords.size.height / 2f
|
||||
)
|
||||
},
|
||||
colors = purrfectSwitchColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ACTIONS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["actions_title"])
|
||||
EnumAction.entries.forEach { enumAction -> RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) } }
|
||||
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
|
||||
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
|
||||
}
|
||||
|
||||
// UI SETTINGS
|
||||
GlassCard {
|
||||
RowTitle(title = translation["ui_settings_title"])
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["haptic_feedback_label"], fontSize = 14.sp)
|
||||
var hapticEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
|
||||
Switch(checked = hapticEnabled, onCheckedChange = { if (it) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); hapticEnabled = it; context.config.root.global.uiSettings.hapticFeedback.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["use_system_toasts_label"], fontSize = 14.sp)
|
||||
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
|
||||
Switch(checked = useSystemToasts, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); useSystemToasts = it; context.config.root.global.uiSettings.useSystemToasts.set(it); context.config.writeConfig() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UPDATES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["updates_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
|
||||
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
|
||||
var channelMenuExpanded by remember { mutableStateOf(false) }
|
||||
ShiftedRow {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(text = translation["auto_update_check"], fontSize = 14.sp)
|
||||
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = autoUpdateCheck) {
|
||||
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
|
||||
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
|
||||
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RESET SETUP
|
||||
GlassCard {
|
||||
RowTitle(title = translation["reset_setup_title"])
|
||||
ShiftedRow(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp).clickable { showResetSetupDialog = true }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = translation["reset_setup_action"], fontSize = 16.sp, fontWeight = FontWeight.Medium, lineHeight = 20.sp)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.padding(end = 14.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// MESSAGE LOGGER
|
||||
GlassCard {
|
||||
RowTitle(title = translation["message_logger_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
|
||||
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
|
||||
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
|
||||
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
|
||||
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
|
||||
}
|
||||
}
|
||||
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
|
||||
if (showImportDialog) {
|
||||
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FRIEND NOTES
|
||||
GlassCard {
|
||||
RowTitle(title = translation["friend_notes_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(text = translation["friend_notes_description"], modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), color = Color.White, textAlign = TextAlign.Center)
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { runCatching { val notes = context.database.getAllScopeNotes(); if (notes.isEmpty()) return@runCatching; val json = context.gson.toJson(notes); activityLauncherHelper.saveFile("notes.json", "application/json") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { it.write(json.toByteArray()) }; context.shortToast(translation["friend_notes_backup_success"]) } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["backup_button"]) }
|
||||
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/json") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { val json = it.reader().readText(); val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type); context.database.setAllScopeNotes(notes); context.shortToast(translation["friend_notes_restore_success"]) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["restore_button"]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG
|
||||
GlassCard {
|
||||
RowTitle(title = translation["debug_title"])
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
|
||||
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.fillMaxWidth()) {
|
||||
AestheticDropdownField(value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName, expanded = expanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { expanded = true })
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
InternalFileHandleType.entries.forEach { fileType -> DropdownMenuItem(onClick = { expanded = false; selectedFileType = fileType }, text = { Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(onClick = { runCatching { scope.launch { selectedFileType.resolve(context.androidContext).delete() } }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), shape = RoundedCornerShape(14.dp)) {
|
||||
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp)); Text(translation["clear_button"])
|
||||
}
|
||||
}
|
||||
ShiftedRow {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
PremiumPreferenceToggle(context.sharedPreferences, key = "test_mode", text = translation["test_mode_label"], defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
|
||||
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_settings"] ?: "Settings",
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
scrollOffset = computedScrollOffset,
|
||||
enableMorph = true,
|
||||
titleAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
routes.navigation?.openBottomBarCustomization = true
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Tune,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.85f)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.R
|
||||
import me.eternal.purrfectsnap.common.data.SocialScope
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState { titles.size }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
updateScopeLists()
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
} else {
|
||||
friendList.filter {
|
||||
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
|
||||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
val filteredGroups = remember(groupList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
groupList
|
||||
} else {
|
||||
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
SocialHeader(
|
||||
titles = titles,
|
||||
pagerState = pagerState,
|
||||
onTabSelected = { index ->
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
},
|
||||
friendCount = friendList.size,
|
||||
groupCount = groupList.size,
|
||||
searchActive = searchActive,
|
||||
onSearchToggle = {
|
||||
searchActive = !searchActive
|
||||
if (!searchActive) searchQuery = ""
|
||||
}
|
||||
)
|
||||
if (searchActive) {
|
||||
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
|
||||
val searchShape = RoundedCornerShape(18.dp)
|
||||
val searchBorder = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
shape = searchShape,
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, searchBorder),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(PurrfectPalette.cardOverlay, searchShape)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = searchHint,
|
||||
tint = PurrfectPalette.textSecondary
|
||||
)
|
||||
BasicTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontSize = 15.sp
|
||||
),
|
||||
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
|
||||
modifier = Modifier.weight(1f)
|
||||
) { innerTextField ->
|
||||
if (searchQuery.isEmpty()) {
|
||||
Text(
|
||||
text = searchHint,
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { searchQuery = "" }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = translation["clear_search_button_description"] ?: "Clear",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
HorizontalPager(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
state = pagerState
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
|
||||
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.task.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
|
||||
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
|
||||
import me.eternal.purrfectsnap.ui.util.scaleOnPress
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
|
||||
val scrollState = rememberLazyListState()
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var controlsHeight by remember { mutableStateOf(100.dp) }
|
||||
|
||||
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
|
||||
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
|
||||
routes.navigation?.globalScrollOffset = offset
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
var showConfirmDialog by remember { mutableStateOf(false) }
|
||||
var alsoDeleteFiles by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchActiveTasks(this)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskSelection.clear()
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
fetchActiveTasks(scope)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
|
||||
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
|
||||
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
|
||||
|
||||
val subtitle = if (activeTasks.isNotEmpty()) {
|
||||
translation.format(
|
||||
"summary_active",
|
||||
"active" to activeTasks.size.toString(),
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
} else {
|
||||
translation.format(
|
||||
"summary_idle",
|
||||
"recent" to recentTasks.size.toString()
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = containerTopPadding),
|
||||
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
|
||||
color = Color.White.copy(alpha = 0.04f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White.copy(alpha = 0.05f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TaskTab.entries.forEach { tab ->
|
||||
val isSelected = selectedTab == tab
|
||||
val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(38.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color.White.copy(alpha = backgroundAlpha))
|
||||
.clickable {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
selectedTab = tab
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"),
|
||||
color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND }
|
||||
val recentList = recentTasks.filter { task ->
|
||||
task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash }
|
||||
}
|
||||
|
||||
val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND }
|
||||
val scheduledRecent = recentTasks.filter { task ->
|
||||
task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = scrollState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = 8.dp,
|
||||
bottom = routes.bottomPadding + 20.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (selectedTab == TaskTab.ACTIVE) {
|
||||
item(key = "auto_open_card") {
|
||||
var queueItems by remember { mutableStateOf(listOf<Any>()) }
|
||||
var processedCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
runCatching {
|
||||
val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface
|
||||
processedCount = autoOpen?.processedCount ?: 0
|
||||
val items = autoOpen?.queueItems ?: emptyList()
|
||||
queueItems = items.mapNotNull {
|
||||
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
|
||||
}
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
val queueSize = queueItems.size
|
||||
|
||||
if (queueSize > 0 || processedCount > 0) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.padding(14.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White)
|
||||
}
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
null,
|
||||
tint = Color.White.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) {
|
||||
Text(
|
||||
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
|
||||
fontSize = 12.sp,
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
queueItems.forEach { rawItem ->
|
||||
val item = rawItem as? Map<String, String> ?: return@forEach
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
|
||||
Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f))
|
||||
}
|
||||
Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0 || queueSize > 0) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(36.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f))
|
||||
) {
|
||||
Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeList.isEmpty() && recentList.isEmpty()) {
|
||||
item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") }
|
||||
}
|
||||
|
||||
val groupedActiveTasks = activeList.distinctBy { it.task.hash }
|
||||
|
||||
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
|
||||
val isAutoOpenTask = pendingTask.task.isAutoOpen
|
||||
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseAlpha by pulseAnimation.animateFloat(
|
||||
initialValue = 0.15f,
|
||||
targetValue = 0.45f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "alpha"
|
||||
)
|
||||
|
||||
AphelionTaskCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.let {
|
||||
if (isAutoOpenTask) {
|
||||
it.border(
|
||||
width = 1.5.dp,
|
||||
brush = Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
|
||||
)
|
||||
),
|
||||
shape = RoundedCornerShape(22.dp)
|
||||
)
|
||||
} else it
|
||||
},
|
||||
task = pendingTask.task,
|
||||
pendingTask = pendingTask
|
||||
)
|
||||
}
|
||||
|
||||
items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
} else {
|
||||
if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) {
|
||||
item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") }
|
||||
}
|
||||
|
||||
items(scheduledActive, key = { it.taskId }) { pendingTask ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
|
||||
}
|
||||
items(scheduledRecent, key = { it.hash }) { task ->
|
||||
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
|
||||
fetchNewRecentTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
|
||||
title = context.translation["manager.routes.tasks"] ?: "Tasks",
|
||||
subtitle = subtitle,
|
||||
scrollOffset = routes.navigation?.globalScrollOffset ?: 0,
|
||||
enableMorph = true,
|
||||
modifier = Modifier.headerHeightTracker { controlsHeight = it },
|
||||
actions = {
|
||||
if (taskSelection.size > 1) {
|
||||
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
|
||||
taskSelection.all { it.second?.type?.contains("video") == true }
|
||||
}
|
||||
|
||||
if (canMergeSelection) {
|
||||
Surface(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
mergeSelection(taskSelection.toList().also {
|
||||
taskSelection.clear()
|
||||
}.map { it.first to it.second!! })
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
|
||||
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
|
||||
Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PlaylistAddCheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = activeTasks.size.toString(),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showConfirmDialog = true
|
||||
}) {
|
||||
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfirmDialog) {
|
||||
val isSelection = taskSelection.isNotEmpty()
|
||||
val titleText = if (isSelection) {
|
||||
translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
|
||||
} else {
|
||||
translation["tasks_remove_all_tasks_confirm"]
|
||||
}
|
||||
val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"]
|
||||
|
||||
TaskDangerDialog(
|
||||
visible = showConfirmDialog,
|
||||
title = titleText,
|
||||
message = messageText,
|
||||
showDeleteFiles = isSelection,
|
||||
deleteFilesChecked = alsoDeleteFiles,
|
||||
onToggleDeleteFiles = { alsoDeleteFiles = it },
|
||||
onConfirm = {
|
||||
showConfirmDialog = false
|
||||
clearTasks(alsoDeleteFiles, scope)
|
||||
},
|
||||
onDismiss = { showConfirmDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TasksRootSection.AphelionTasksEmptyState(text: String) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.08f),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(58.dp)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.28f)
|
||||
)
|
||||
),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = text,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
|
||||
var taskStatus by remember { mutableStateOf(task.status) }
|
||||
var taskProgressLabel by remember { mutableStateOf<String?>(null) }
|
||||
var taskProgress by remember { mutableIntStateOf(-1) }
|
||||
val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } }
|
||||
|
||||
var documentFileMimeType by remember { mutableStateOf("") }
|
||||
var isDocumentFileReadable by remember { mutableStateOf(true) }
|
||||
|
||||
val docVal = task.extra?.toUri()
|
||||
val documentFile by rememberAsyncMutableState(
|
||||
defaultValue = null as DocumentFile?,
|
||||
keys = arrayOf(taskStatus.name)
|
||||
) {
|
||||
if (docVal == null) null
|
||||
else DocumentFile.fromSingleUri(context.androidContext, docVal)?.apply {
|
||||
documentFileMimeType = type ?: ""
|
||||
isDocumentFileReadable = canRead()
|
||||
}
|
||||
}
|
||||
|
||||
val listener = remember { PendingTaskListener(
|
||||
onStateChange = { taskStatus = it },
|
||||
onProgress = { label, progress ->
|
||||
taskProgressLabel = label
|
||||
taskProgress = progress
|
||||
}
|
||||
) }
|
||||
|
||||
LaunchedEffect(Unit) { pendingTask?.addListener(listener) }
|
||||
DisposableEffect(Unit) { onDispose { pendingTask?.removeListener(listener) } }
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val isActive = pendingTask != null && !taskStatus.isFinalStage()
|
||||
|
||||
fun toggleSelection() {
|
||||
if (isSelected) {
|
||||
taskSelection.removeIf { it.first == task }
|
||||
return
|
||||
}
|
||||
taskSelection.add(task to documentFile)
|
||||
}
|
||||
|
||||
fun openFile() {
|
||||
if (!isDocumentFileReadable || documentFile == null) return
|
||||
runCatching {
|
||||
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(documentFile!!.uri, documentFile!!.type)
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
})
|
||||
}.onFailure {
|
||||
context.log.error("Failed to open file ${documentFile?.uri}", it)
|
||||
context.shortToast(translation["failed_to_open_file"] ?: "Failed to open file")
|
||||
}
|
||||
}
|
||||
|
||||
val cardModifier = modifier
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
toggleSelection()
|
||||
return@detectTapGestures
|
||||
}
|
||||
openFile()
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (taskSelection.isNotEmpty()) {
|
||||
openFile()
|
||||
return@detectTapGestures
|
||||
}
|
||||
toggleSelection()
|
||||
}
|
||||
)
|
||||
}
|
||||
.let {
|
||||
if (isSelected) {
|
||||
it.border(2.dp, PurrfectPalette.glowSecondary, RoundedCornerShape(22.dp)).clip(RoundedCornerShape(22.dp))
|
||||
} else it
|
||||
}
|
||||
|
||||
val chipLabel = when {
|
||||
isActive -> translation.getOrNull("task_sending") ?: "Sending"
|
||||
taskStatus == TaskStatus.SUCCESS -> null
|
||||
taskStatus == TaskStatus.FAILURE -> translation.getOrNull("task_failed") ?: "Failed"
|
||||
taskStatus == TaskStatus.CANCELLED -> translation.getOrNull("task_cancelled") ?: "Cancelled"
|
||||
else -> taskStatus.name.lowercase().replaceFirstChar { it.titlecase() }
|
||||
}
|
||||
val chipIcon = when {
|
||||
isActive -> null
|
||||
taskStatus == TaskStatus.SUCCESS -> Icons.Filled.Check
|
||||
taskStatus == TaskStatus.FAILURE -> Icons.Filled.WarningAmber
|
||||
taskStatus == TaskStatus.CANCELLED -> Icons.Filled.Cancel
|
||||
else -> Icons.Filled.Info
|
||||
}
|
||||
val chipColors = when {
|
||||
isActive -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.08f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.SUCCESS -> AssistChipDefaults.assistChipColors()
|
||||
taskStatus == TaskStatus.FAILURE -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(0xFFFF6B9B).copy(alpha = 0.18f),
|
||||
labelColor = Color.White
|
||||
)
|
||||
taskStatus == TaskStatus.CANCELLED -> AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color.White.copy(alpha = 0.06f),
|
||||
labelColor = PurrfectPalette.textSecondary
|
||||
)
|
||||
else -> AssistChipDefaults.assistChipColors()
|
||||
}
|
||||
val countdownText = if (isActive) {
|
||||
taskProgressLabel?.let { label ->
|
||||
Regex("""(\d+d\s+)?(\d+h\s+)?(\d+m\s+)?\d+s""").find(label)?.value?.trim() ?: label
|
||||
}
|
||||
} else null
|
||||
|
||||
val cardShape = RoundedCornerShape(22.dp)
|
||||
Surface(
|
||||
modifier = cardModifier,
|
||||
shape = cardShape,
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, if (isSelected) Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)) else SolidColor(Color.White.copy(alpha = 0.1f)))
|
||||
) {
|
||||
Row(modifier = Modifier.background(PurrfectPalette.cardOverlay, cardShape).padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.padding(end = 15.dp).size(50.dp).clipToBounds(), contentAlignment = Alignment.Center) {
|
||||
var loadFailed by remember { mutableStateOf(false) }
|
||||
val doc = documentFile
|
||||
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && doc != null && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
|
||||
val imageRequest = ImageRequest.Builder(context.androidContext)
|
||||
.data(doc.uri)
|
||||
.cacheKey(doc.uri.toString())
|
||||
.placeholder(ColorDrawable(PurrfectPalette.cardOverlayColor.toArgb()))
|
||||
.build()
|
||||
Image(
|
||||
painter = rememberAsyncImagePainter(
|
||||
model = imageRequest,
|
||||
imageLoader = context.imageLoader,
|
||||
onState = { state ->
|
||||
if (state is coil.compose.AsyncImagePainter.State.Error) loadFailed = true
|
||||
}
|
||||
),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.size(50.dp).clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = null)
|
||||
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = null)
|
||||
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = null)
|
||||
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = null)
|
||||
else -> Icon(Icons.Filled.FileCopy, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (task.type == TaskType.SCHEDULED_SEND) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", style = MaterialTheme.typography.labelMedium, color = PurrfectPalette.textSecondary)
|
||||
Text(task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||
task.author?.takeIf { it != "null" }?.let { recipients ->
|
||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Icon(Icons.Filled.People, contentDescription = null, modifier = Modifier.size(16.dp).padding(top = 2.dp), tint = PurrfectPalette.textSecondary)
|
||||
recipients.split(", ").let { list ->
|
||||
Text(list.joinToString(", "), style = MaterialTheme.typography.bodyMedium, color = Color.White, lineHeight = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(task.title, style = MaterialTheme.typography.bodyMedium, color = Color.White)
|
||||
task.author?.takeIf { it != "null" }?.let {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
}
|
||||
Text(task.hash, style = MaterialTheme.typography.labelSmall, color = PurrfectPalette.textSecondary)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(top = 5.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
chipLabel?.let { label ->
|
||||
val leadingIcon: (@Composable () -> Unit)? = if (isActive && task.type == TaskType.SCHEDULED_SEND) {
|
||||
{ Icon(Icons.Filled.Timer, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
} else if (isActive) {
|
||||
countdownText?.let { countdown -> { Text(countdown, style = MaterialTheme.typography.labelSmall) } }
|
||||
} else chipIcon?.let { icon -> { Icon(icon, contentDescription = null) } }
|
||||
|
||||
val displayLabel = if (isActive && task.type == TaskType.SCHEDULED_SEND && countdownText != null) {
|
||||
translation.getOrNull("schedule_sending_in")?.replace("{time}", countdownText) ?: "Sending in $countdownText"
|
||||
} else label
|
||||
|
||||
AssistChip(onClick = {}, enabled = false, leadingIcon = leadingIcon, label = { Text(displayLabel) }, colors = chipColors)
|
||||
}
|
||||
|
||||
if (!taskStatus.isFinalStage()) {
|
||||
if (isActive) {
|
||||
taskProgressLabel?.let {
|
||||
val labelText = if (task.isAutoOpen) {
|
||||
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0
|
||||
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
|
||||
"$it • $speed snaps/min"
|
||||
} else it
|
||||
Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White)
|
||||
}
|
||||
} else {
|
||||
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
|
||||
}
|
||||
if (taskProgress != -1 && (taskProgressLabel == null || isActive)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { taskProgress.toFloat() / 100f },
|
||||
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),
|
||||
color = PurrfectPalette.glowSecondary, trackColor = Color.White.copy(alpha = 0.12f)
|
||||
)
|
||||
}
|
||||
if (!isActive) {
|
||||
task.extra?.takeIf { it.isNotEmpty() }?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = PurrfectPalette.textSecondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
if (isActive) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
runCatching { pendingTask?.cancel() }.onFailure { throwable ->
|
||||
context.log.error("Failed to cancel task $pendingTask", throwable)
|
||||
}
|
||||
},
|
||||
colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color(0xFFFF6B9B).copy(alpha = 0.35f), contentColor = Color.White)
|
||||
) { Icon(Icons.Filled.Close, contentDescription = "Cancel") }
|
||||
} else if (taskStatus == TaskStatus.SUCCESS) {
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn(tween(250)) + scaleIn(tween(300)),
|
||||
exit = fadeOut(tween(150)) + scaleOut(targetScale = 0.5f, animationSpec = tween(150))
|
||||
) {
|
||||
Box(modifier = Modifier.size(40.dp).clip(CircleShape).background(PurrfectPalette.glowPrimary.copy(alpha = 0.22f)), contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Success", tint = Color.White)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
when (taskStatus) {
|
||||
TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = Color(0xFFFF6B9B))
|
||||
TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = Color(0xFFFF6B9B))
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.ThemeContract
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
|
||||
|
||||
object AphelionTheme : ThemeContract {
|
||||
@Composable
|
||||
override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) {
|
||||
AphelionHomeScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) {
|
||||
AphelionSettingsScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) {
|
||||
AphelionAboutScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
|
||||
AphelionLogsScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) {
|
||||
AphelionSocialScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
|
||||
AphelionTasksScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) {
|
||||
AphelionFeaturesScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) {
|
||||
AphelionScriptingScreen(nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) {
|
||||
TrackerScreenContent(nav)
|
||||
}
|
||||
}
|
||||
@@ -148,7 +148,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(translation["manage_repos_description"] ?: (context.translation["manager.routes.manage_friend_tracker_repos"] ?: "Manage repositories"))
|
||||
Text(translation["manage_repos_description"] ?: context.translation["manager.routes.manage_friend_tracker_repos"])
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -176,7 +176,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
|
||||
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ class FriendTrackerCatalog : Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = translation["title"] ?: (routeInfo.translatedKey?.value ?: "Catalog"),
|
||||
title = translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
|
||||
|
||||
@@ -115,7 +115,7 @@ class FriendTrackerConfigExportScreen : Routes.Route() {
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("export_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -78,7 +78,7 @@ class FriendTrackerConfigImportScreen : Routes.Route() {
|
||||
routes.onRuleImported?.invoke()
|
||||
routes.navController.popBackStack()
|
||||
}.onFailure {
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.longToast(translation.format("import_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
}) {
|
||||
Text(translation["confirm_button"])
|
||||
|
||||
@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
|
||||
import androidx.compose.material.icons.filled.Store
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
@@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
|
||||
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
|
||||
import me.eternal.purrfectsnap.storage.*
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
|
||||
@@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
|
||||
private val titles by lazy {
|
||||
internal val titles by lazy {
|
||||
listOf(
|
||||
translation["rules_tab"],
|
||||
translation["logs_tab"]
|
||||
)
|
||||
}
|
||||
private var currentPage by mutableIntStateOf(0)
|
||||
private lateinit var logDeleteAction : () -> Unit
|
||||
private lateinit var exportAction : () -> Unit
|
||||
internal var currentPage by mutableIntStateOf(0)
|
||||
internal lateinit var logDeleteAction : () -> Unit
|
||||
internal lateinit var exportAction : () -> Unit
|
||||
|
||||
@Composable
|
||||
private fun TrackerIconButton(
|
||||
internal fun TrackerIconButton(
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerActionButton(
|
||||
internal fun TrackerActionButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPillButton(
|
||||
internal fun TrackerPillButton(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
onClick: () -> Unit,
|
||||
@@ -248,7 +244,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
context.longToast(
|
||||
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
|
||||
|
||||
override val init: () -> Unit = {
|
||||
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
|
||||
@@ -345,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigRulesTab() {
|
||||
internal fun ConfigRulesTab() {
|
||||
val updateRules = rememberAsyncUpdateDispatcher()
|
||||
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
|
||||
context.database.getTrackerRulesDesc()
|
||||
@@ -566,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = {
|
||||
@Composable
|
||||
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
|
||||
currentPage = pagerState.currentPage
|
||||
@@ -591,7 +589,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
routes.friendTrackerConfigJsonForImport = content
|
||||
routes.friendTrackerConfigImport.navigate()
|
||||
}.onFailure {
|
||||
context.longToast("Failed to read file: ${it.message}")
|
||||
context.longToast(
|
||||
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = context.translation["manager.routes.friend_tracker"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
text = titles.getOrNull(pagerState.currentPage) ?: "",
|
||||
color = PurrfectPalette.textSecondary,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
if (pagerState.currentPage == 0) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
TrackerPillButton(
|
||||
label = translation["import_button"],
|
||||
icon = Icons.Default.FolderOpen,
|
||||
onClick = { showImportDialog = true }
|
||||
)
|
||||
TrackerPillButton(
|
||||
label = translation["export_button"],
|
||||
icon = Icons.Default.SaveAlt,
|
||||
onClick = { showExportDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -792,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
|
||||
val themeId by produceState(
|
||||
initialValue = context.config.root.global.uiSettings.managerTheme.get()
|
||||
) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.global.uiSettings.managerTheme.get()
|
||||
}
|
||||
}
|
||||
key(themeId) {
|
||||
with(ManagerTheme.fromId(themeId).theme) {
|
||||
this@FriendTrackerManagerRoot.FriendTrackerScreen(nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -245,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to add repository", it)
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
|
||||
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
@@ -282,7 +282,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
|
||||
.background(PurrfectPalette.backgroundGradient)
|
||||
) {
|
||||
FloatingTopBar(
|
||||
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
|
||||
title = routeInfo.translatedKey?.value ?: translation["title"],
|
||||
onBack = { routes.navController.popBackStack() },
|
||||
modifier = Modifier
|
||||
.zIndex(2f)
|
||||
|
||||
@@ -16,10 +16,14 @@ class TrackerConfigParser(private val context: Routes.Route) {
|
||||
fun parse(configJson: String): Map<String, List<ImportedFeature>> {
|
||||
val featureMap = mutableMapOf<String, MutableList<ImportedFeature>>()
|
||||
val exportedData = context.context.gson.fromJson(configJson, ExportedTrackerData::class.java)
|
||||
val authorLabel = context.translation["tracker_author_label"]
|
||||
val enabledLabel = context.translation["tracker_enabled_label"]
|
||||
val enabledValue = context.translation["tracker_enabled_value"]
|
||||
val disabledValue = context.translation["tracker_disabled_value"]
|
||||
exportedData.rules.forEach { rule ->
|
||||
val features = mutableListOf<ImportedFeature>()
|
||||
features.add(ImportedFeature(rule.name, "Author", "author", rule.author ?: "Unknown", 0))
|
||||
features.add(ImportedFeature(rule.name, "Enabled", "enabled", rule.enabled, 0))
|
||||
features.add(ImportedFeature(rule.name, authorLabel, "author", rule.author ?: context.context.translation["common.unknown"], 0))
|
||||
features.add(ImportedFeature(rule.name, enabledLabel, "enabled", if (rule.enabled) enabledValue else disabledValue, 0))
|
||||
rule.events?.forEach { event ->
|
||||
features.add(ImportedFeature(rule.name, context.context.translation["tracker_events.${event.eventType}"], event.eventType, event.actions.joinToString(", ") { context.context.translation["tracker_actions.${it.key}"] }, 1))
|
||||
}
|
||||
@@ -30,7 +34,7 @@ class TrackerConfigParser(private val context: Routes.Route) {
|
||||
|
||||
fun parseValue(featureKey: String, value: Any): Any {
|
||||
return when (value) {
|
||||
is Boolean -> if (value) "Enabled" else "Disabled"
|
||||
is Boolean -> if (value) context.translation["tracker_enabled_value"] else context.translation["tracker_disabled_value"]
|
||||
is JSONArray -> {
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until value.length()) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
/**
|
||||
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
|
||||
* Provides more nuanced feedback than standard Compose haptics.
|
||||
*/
|
||||
object AphelionHaptics {
|
||||
|
||||
/**
|
||||
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
|
||||
*/
|
||||
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
val vibrator = vibratorManager?.defaultVibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||
} else {
|
||||
// Fallback for older APIs
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a "soft" impact feel, good for glass interactions.
|
||||
*/
|
||||
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
|
||||
runCatching {
|
||||
if (!shouldPerformHaptics(remoteSideContext)) return
|
||||
|
||||
val androidContext = remoteSideContext.androidContext
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||
} else {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
}.onFailure { it.printStackTrace() }
|
||||
}
|
||||
|
||||
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
|
||||
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sqrt
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import me.eternal.purrfectsnap.RemoteSideContext
|
||||
|
||||
private const val REVEAL_DURATION_MS = 3200
|
||||
private const val WAVE_BAND_WIDTH_PX = 300f
|
||||
|
||||
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
|
||||
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
|
||||
|
||||
@Composable
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
fun CircularRevealOverlay(
|
||||
context: RemoteSideContext,
|
||||
request: ThemeRevealRequest,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
// Safety check: if bitmap was recycled or is null, skip.
|
||||
val bitmap = request.oldThemeBitmap ?: run {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
if (bitmap.isRecycled) {
|
||||
LaunchedEffect(request.id) { onComplete() }
|
||||
return
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
// Ensure the reveal is cleared even if navigation happens mid-animation
|
||||
DisposableEffect(request.id) {
|
||||
onDispose { onComplete() }
|
||||
}
|
||||
|
||||
val maxRadius = remember(configuration) {
|
||||
with(density) {
|
||||
val w = configuration.screenWidthDp.dp.toPx()
|
||||
val h = configuration.screenHeightDp.dp.toPx()
|
||||
sqrt(w * w + h * h)
|
||||
}
|
||||
}
|
||||
|
||||
val animatedRadius = remember(request.id) { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(request.id) {
|
||||
AphelionHaptics.themeRevealTick(context, hapticFeedback)
|
||||
|
||||
animatedRadius.animateTo(
|
||||
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
|
||||
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
|
||||
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
|
||||
val timeValue by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 10f,
|
||||
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
|
||||
label = "wave_time_value"
|
||||
)
|
||||
|
||||
// --- AGSL SHADER LOGIC (Android 13+) ---
|
||||
|
||||
val runtimeShader = remember(bitmap) {
|
||||
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
|
||||
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
|
||||
}
|
||||
}
|
||||
|
||||
val shaderPaint = remember(runtimeShader) {
|
||||
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
shader = runtimeShader
|
||||
}
|
||||
}
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val radius = animatedRadius.value
|
||||
val center = request.originCenter
|
||||
|
||||
drawIntoCanvas { canvas ->
|
||||
runtimeShader.setFloatUniform("revealRadius", radius)
|
||||
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
|
||||
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
|
||||
runtimeShader.setFloatUniform("time", timeValue)
|
||||
runtimeShader.setFloatUniform("uProgress", progress)
|
||||
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
/**
|
||||
* Carries all data needed to execute one theme reveal transition.
|
||||
*/
|
||||
data class ThemeRevealRequest(
|
||||
val newThemeId: String,
|
||||
val originCenter: Offset,
|
||||
val oldThemeBitmap: android.graphics.Bitmap?,
|
||||
val id: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Observable state that lives on the [Navigation] instance.
|
||||
* Optimized for stability during rapid toggle events.
|
||||
*/
|
||||
class ThemeRevealState {
|
||||
|
||||
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Requests a new theme reveal animation.
|
||||
* Always starts a new reveal immediately, even if one is already in progress.
|
||||
*/
|
||||
fun requestReveal(
|
||||
newThemeId: String,
|
||||
originCenter: Offset,
|
||||
bitmap: android.graphics.Bitmap?
|
||||
) {
|
||||
// Clean up the old one first to prevent memory leaks and "dead periods"
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
|
||||
// Immediately update with the new request ID to force a fresh animation
|
||||
pendingReveal = ThemeRevealRequest(
|
||||
newThemeId = newThemeId,
|
||||
originCenter = originCenter,
|
||||
oldThemeBitmap = bitmap,
|
||||
id = System.currentTimeMillis() // Unique ID ensures fresh start
|
||||
)
|
||||
}
|
||||
|
||||
/** Called by the overlay composable once the animation has fully completed. */
|
||||
fun clearReveal() {
|
||||
val oldBitmap = pendingReveal?.oldThemeBitmap
|
||||
pendingReveal = null
|
||||
|
||||
// Manual memory management for the heavy screenshot bitmap
|
||||
if (oldBitmap?.isRecycled == false) {
|
||||
oldBitmap.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
|
||||
|
||||
/**
|
||||
* Finalized "Perfect Optic" AGSL Shader.
|
||||
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
|
||||
*/
|
||||
object WaveEdgeShader {
|
||||
|
||||
const val AGSL = """
|
||||
uniform shader content;
|
||||
uniform float revealRadius;
|
||||
uniform float2 revealCenter;
|
||||
uniform float bandWidth;
|
||||
uniform float time;
|
||||
uniform float uProgress;
|
||||
|
||||
float hash(float2 p) {
|
||||
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
|
||||
}
|
||||
|
||||
float valueNoise(float2 p) {
|
||||
float2 i = floor(p);
|
||||
float2 f = fract(p);
|
||||
float2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
|
||||
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
|
||||
u.y
|
||||
);
|
||||
}
|
||||
|
||||
half4 main(float2 pos) {
|
||||
float d = distance(pos, revealCenter);
|
||||
|
||||
float energy = (1.0 - uProgress);
|
||||
// Slower, more majestic large noise
|
||||
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
|
||||
float totalNoise = noiseLarge * energy;
|
||||
|
||||
float dist = d - (revealRadius + totalNoise);
|
||||
|
||||
if (dist > 0.0) {
|
||||
return content.eval(pos);
|
||||
}
|
||||
|
||||
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
|
||||
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
|
||||
float effectStart = revealRadius - dynamicBand;
|
||||
|
||||
if (dist < -dynamicBand) {
|
||||
return half4(0.0);
|
||||
}
|
||||
|
||||
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
|
||||
|
||||
// Asymmetric crest: Sharp start, long slow tail
|
||||
float waveShape = pow(bandProgress, 2.0);
|
||||
|
||||
float2 dir = normalize(pos - revealCenter + 0.001);
|
||||
|
||||
// Refraction: Impactful but clear
|
||||
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
|
||||
float2 refractedPos = pos + dir * refractionAmt;
|
||||
|
||||
// Chromatic Aberration: Deep prism split
|
||||
float aberration = waveShape * (35.0 * energy + 10.0);
|
||||
half r = content.eval(refractedPos + dir * aberration).r;
|
||||
half g = content.eval(refractedPos).g;
|
||||
half b = content.eval(refractedPos - dir * aberration).b;
|
||||
|
||||
// Sharp highlight at the front edge
|
||||
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
|
||||
|
||||
// Leading edge softening (minor)
|
||||
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
|
||||
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
|
||||
|
||||
return half4(
|
||||
r + half(highlight),
|
||||
g + half(highlight),
|
||||
b + half(highlight),
|
||||
half(alpha)
|
||||
);
|
||||
}
|
||||
"""
|
||||
}
|
||||
@@ -78,6 +78,7 @@ class RemoteOverlay(
|
||||
fun close() {
|
||||
if (!::dialog.isInitialized || !dialog.isShowing) return
|
||||
dismissCallback = null
|
||||
context.sharedPreferences.edit().putBoolean("overlay_active", false).apply()
|
||||
context.androidContext.mainExecutor.execute {
|
||||
dialog.dismiss()
|
||||
}
|
||||
@@ -92,6 +93,7 @@ class RemoteOverlay(
|
||||
return
|
||||
}
|
||||
|
||||
context.sharedPreferences.edit().putBoolean("overlay_active", true).apply()
|
||||
context.androidContext.mainExecutor.execute {
|
||||
dialog = object: Dialog(context.androidContext, R.style.FullscreenOverlayDialog) {
|
||||
override fun dismiss() {
|
||||
@@ -99,6 +101,9 @@ class RemoteOverlay(
|
||||
if (it()) return
|
||||
}
|
||||
super.dismiss()
|
||||
this@RemoteOverlay.context.sharedPreferences.edit()
|
||||
.putBoolean("overlay_active", false)
|
||||
.apply()
|
||||
this@RemoteOverlay.context.config.writeConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package me.eternal.purrfectsnap.ui.setup
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -78,6 +79,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -203,6 +205,8 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
setContent {
|
||||
val context = LocalContext.current
|
||||
val translation = setupContext.translation
|
||||
val navController = rememberNavController()
|
||||
var canGoNext by remember { mutableStateOf(false) }
|
||||
var lastRoute by rememberSaveable { mutableStateOf("") }
|
||||
@@ -214,6 +218,31 @@ class SetupActivity : ComponentActivity() {
|
||||
}
|
||||
val skipPatch by rememberSaveable { skipPatchChoice }
|
||||
val installMode by installModeChoice
|
||||
val shouldShowAbiWarning = remember {
|
||||
val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") }
|
||||
val libDir = context.applicationInfo.nativeLibraryDir.orEmpty()
|
||||
val appIsArm64 = libDir.contains("arm64")
|
||||
deviceIsArm64 && !appIsArm64
|
||||
}
|
||||
if (shouldShowAbiWarning) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = {},
|
||||
title = translation["setup.activity.wrong_apk_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = translation["setup.activity.close_button"],
|
||||
onConfirm = { (context as? Activity)?.finishAffinity() },
|
||||
showCloseButton = false,
|
||||
opaque = true,
|
||||
customContent = {
|
||||
Text(
|
||||
text = translation["setup.activity.wrong_apk_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
val visibleScreens = remember(skipPatch, installMode) {
|
||||
requiredScreens.filterNot { screen ->
|
||||
if (skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) {
|
||||
@@ -316,7 +345,14 @@ class SetupActivity : ComponentActivity() {
|
||||
.background(Color.Transparent)
|
||||
) {
|
||||
if (showImportantDialog) {
|
||||
val confirmLabel = if (importantTimeout > 0) "I understand (${importantTimeout}s)" else "I understand"
|
||||
val confirmLabel = if (importantTimeout > 0) {
|
||||
translation.format(
|
||||
"setup.activity.important_confirm_timeout",
|
||||
"seconds" to importantTimeout.toString()
|
||||
)
|
||||
} else {
|
||||
translation["setup.activity.important_confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = {
|
||||
if (importantTimeout == 0) {
|
||||
@@ -324,7 +360,7 @@ class SetupActivity : ComponentActivity() {
|
||||
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
|
||||
}
|
||||
},
|
||||
title = "Important!",
|
||||
title = translation["setup.activity.important_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
@@ -338,7 +374,7 @@ class SetupActivity : ComponentActivity() {
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
Text(
|
||||
text = "If you have used SnapEnhance or any other mod besides PurrfectSnap, we recommend uninstalling everything and staying on stock Snapchat for one week. Then switch to PurrfectSnap after next Friday.",
|
||||
text = translation["setup.activity.important_message"],
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
@@ -443,50 +479,50 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
|
||||
return when (this) {
|
||||
is PickLanguageScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.dialogs.select_language"] ?: "Choose your language",
|
||||
subtitle = "Tune PurrfectSnap to speak your voice before anything else.",
|
||||
title = translation["setup.dialogs.select_language"],
|
||||
subtitle = translation["setup.activity.language_subtitle"],
|
||||
icon = Icons.Filled.Language
|
||||
)
|
||||
|
||||
is InstallModeScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Choose your device",
|
||||
subtitle = "Pick the path that matches how you'll install PurrfectSnap.",
|
||||
title = translation["setup.activity.install_mode_title"],
|
||||
subtitle = translation["setup.activity.install_mode_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser
|
||||
)
|
||||
|
||||
is PermissionsScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.permissions.dialog"] ?: "Essential permissions",
|
||||
subtitle = "Grant the essentials so overlays, downloads, and alerts stay reliable.",
|
||||
title = translation["setup.permissions.dialog"],
|
||||
subtitle = translation["setup.activity.permissions_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser
|
||||
)
|
||||
|
||||
is PatchSnapchatScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Auto Patcher",
|
||||
subtitle = "Streamlined download, patch, and install with a single flow.",
|
||||
title = translation["setup.activity.patch_title"],
|
||||
subtitle = translation["setup.activity.patch_subtitle"],
|
||||
icon = Icons.Filled.Download
|
||||
)
|
||||
|
||||
is RootInstallSnapchatScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = "Snapchat Installer",
|
||||
subtitle = "Download and install the recommended Snapchat build.",
|
||||
title = translation["setup.activity.root_install_title"],
|
||||
subtitle = translation["setup.activity.root_install_subtitle"],
|
||||
icon = Icons.Filled.Download
|
||||
)
|
||||
|
||||
is SaveFolderScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.dialogs.save_folder"] ?: "Where should we save?",
|
||||
subtitle = "Pick your personal vault so snaps land exactly where you expect.",
|
||||
title = translation["setup.dialogs.save_folder"],
|
||||
subtitle = translation["setup.activity.save_folder_subtitle"],
|
||||
icon = Icons.Filled.Folder
|
||||
)
|
||||
|
||||
is MappingsScreen -> SetupStepMeta(
|
||||
route = route,
|
||||
title = translation["setup.mappings.dialog"] ?: "Mapping your Snapchat",
|
||||
subtitle = "We calibrate everything to your install so the magic works flawlessly.",
|
||||
title = translation["setup.mappings.dialog"],
|
||||
subtitle = translation["setup.activity.mappings_subtitle"],
|
||||
icon = Icons.Filled.AutoAwesome
|
||||
)
|
||||
|
||||
@@ -595,6 +631,7 @@ private fun SetupHeader(
|
||||
currentIndex: Int,
|
||||
total: Int
|
||||
) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -620,7 +657,11 @@ private fun SetupHeader(
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Step ${currentIndex + 1} of $total",
|
||||
text = translation.format(
|
||||
"setup.activity.step_counter",
|
||||
"current" to (currentIndex + 1).toString(),
|
||||
"total" to total.toString()
|
||||
),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp
|
||||
@@ -695,6 +736,7 @@ private fun StepBadgesRow(steps: List<SetupStepMeta>, currentStep: Int) {
|
||||
|
||||
@Composable
|
||||
private fun StepBadge(step: SetupStepMeta, state: StepState) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
val baseColor = when (state) {
|
||||
StepState.COMPLETE -> PurrfectPalette.glowSecondary
|
||||
StepState.ACTIVE -> PurrfectPalette.glowPrimary
|
||||
@@ -746,9 +788,9 @@ private fun StepBadge(step: SetupStepMeta, state: StepState) {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val hint = when (state) {
|
||||
StepState.COMPLETE -> "Checked off"
|
||||
StepState.ACTIVE -> "In progress"
|
||||
StepState.UPCOMING -> "Ready next"
|
||||
StepState.COMPLETE -> translation["setup.activity.step_complete"]
|
||||
StepState.ACTIVE -> translation["setup.activity.step_active"]
|
||||
StepState.UPCOMING -> translation["setup.activity.step_upcoming"]
|
||||
}
|
||||
Text(
|
||||
text = hint,
|
||||
@@ -840,6 +882,7 @@ private fun NextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val translation = SharedContextHolder.remote(LocalContext.current).translation
|
||||
val alpha by animateFloatAsState(targetValue = if (enabled) 1f else 0.6f, label = "NextButtonAlpha")
|
||||
val gradient = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -875,7 +918,11 @@ private fun NextButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isFinalStep) "Finish setup" else "Continue",
|
||||
text = if (isFinalStep) {
|
||||
translation["setup.activity.finish_button"]
|
||||
} else {
|
||||
translation["setup.activity.continue_button"]
|
||||
},
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp
|
||||
|
||||
@@ -98,10 +98,14 @@ class InstallModeScreen(
|
||||
}
|
||||
|
||||
if (showGuides) {
|
||||
val confirmLabel = if (timeout > 0) "I understand (${timeout}s)" else "I understand"
|
||||
val confirmLabel = if (timeout > 0) {
|
||||
context.translation.format("setup.install_mode.confirm_timeout", "seconds" to timeout.toString())
|
||||
} else {
|
||||
context.translation["setup.install_mode.confirm"]
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (timeout == 0) showGuides = false },
|
||||
title = "Please note!",
|
||||
title = context.translation["setup.install_mode.notice_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
@@ -138,39 +142,39 @@ class InstallModeScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Select the type of device you have: rooted or non-rooted. If you are unsure, choose Non-root and continue.",
|
||||
text = context.translation["setup.install_mode.notice_intro"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Non-rooted devices",
|
||||
text = context.translation["setup.install_mode.notice_non_root_title"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "Select Non-root and the app will handle everything. Tap Install Patched Snapchat when it appears. After it installs, do not open Snapchat yet. Continue the PurrfectSnap setup; once it finishes, you can open Snapchat and enjoy.",
|
||||
text = context.translation["setup.install_mode.notice_non_root_body"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Rooted devices",
|
||||
text = context.translation["setup.install_mode.notice_root_title"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "Make sure you have flashed LSPosed first. We recommend JingMatrix LSPosed or LSPosed Irena. After you select Root, the app will install the recommended Snapchat version. Do not open it yet; continue the PurrfectSnap setup. When setup finishes, enable PurrfectSnap in LSPosed and reboot your phone. Then start using Snapchat. We highly recommend detaching Snapchat from the Play Store with the Zygisk Detach module to prevent auto-updates.",
|
||||
text = context.translation["setup.install_mode.notice_root_body"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "If you run into any installation issues, the solution will appear here. Please read it carefully.",
|
||||
text = context.translation["setup.install_mode.notice_issues_hint"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -178,9 +182,9 @@ class InstallModeScreen(
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.White)) {
|
||||
append("Note: ")
|
||||
append(context.translation["setup.install_mode.notice_note_prefix"])
|
||||
}
|
||||
append("New Accounts easily get locked! It is recommended to use an older account with PurrfectSnap.")
|
||||
append(context.translation["setup.install_mode.notice_note_body"])
|
||||
},
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
@@ -194,8 +198,8 @@ class InstallModeScreen(
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Choose your device",
|
||||
subtitle = "If you don't know, select Non-rooted device and proceed.",
|
||||
title = context.translation["setup.install_mode.step_title"],
|
||||
subtitle = context.translation["setup.install_mode.step_subtitle"],
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Column(
|
||||
@@ -203,8 +207,8 @@ class InstallModeScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
ModeOption(
|
||||
title = "Rooted device",
|
||||
subtitle = "Use Lsposed and skip auto patching.",
|
||||
title = context.translation["setup.install_mode.root_option_title"],
|
||||
subtitle = context.translation["setup.install_mode.root_option_subtitle"],
|
||||
icon = Icons.Filled.VerifiedUser,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -219,8 +223,8 @@ class InstallModeScreen(
|
||||
}
|
||||
)
|
||||
ModeOption(
|
||||
title = "Non-rooted device",
|
||||
subtitle = "Use included auto patcher to install patched Snapchat.",
|
||||
title = context.translation["setup.install_mode.non_root_option_title"],
|
||||
subtitle = context.translation["setup.install_mode.non_root_option_subtitle"],
|
||||
icon = Icons.Filled.Shield,
|
||||
accent = Brush.horizontalGradient(
|
||||
listOf(
|
||||
@@ -262,7 +266,7 @@ class InstallModeScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Skip Auto Setup",
|
||||
text = context.translation["setup.install_mode.skip_auto_setup"],
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
|
||||
@@ -11,21 +11,14 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -34,15 +27,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
import me.eternal.purrfectsnap.ui.util.AlertDialogs
|
||||
@@ -52,18 +41,10 @@ class MappingsScreen : SetupScreen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val translation = context.translation
|
||||
var infoText by remember { mutableStateOf(null as String?) }
|
||||
var isGenerating by remember { mutableStateOf(false) }
|
||||
var showCompletionNotice by remember { mutableStateOf(false) }
|
||||
var completionCountdown by remember { mutableIntStateOf(10) }
|
||||
|
||||
fun finishMappings() {
|
||||
if (isFirstRunFlow) {
|
||||
showCompletionNotice = true
|
||||
} else {
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
fun finishMappings() = goNext()
|
||||
|
||||
if (infoText != null) {
|
||||
fun dismiss() {
|
||||
@@ -86,100 +67,6 @@ class MappingsScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showCompletionNotice) {
|
||||
if (showCompletionNotice) {
|
||||
completionCountdown = 10
|
||||
while (completionCountdown > 0) {
|
||||
delay(1000)
|
||||
completionCountdown--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompletionNotice) {
|
||||
val confirmLabel = if (completionCountdown > 0) {
|
||||
"I understand (${completionCountdown}s)"
|
||||
} else {
|
||||
"I understand"
|
||||
}
|
||||
AestheticDialog(
|
||||
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
title = "Please note!",
|
||||
text = "",
|
||||
icon = Icons.Filled.Warning,
|
||||
confirmButtonText = confirmLabel,
|
||||
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
|
||||
confirmEnabled = completionCountdown == 0,
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = PurrfectPalette.textSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = PurrfectPalette.cardOverlayColor,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
Brush.linearGradient(
|
||||
listOf(
|
||||
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
|
||||
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 360.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "If you see the \"Account temporarily disabled\" error while logging in, do not worry. Follow these steps in order:",
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "1. Reopen Snapchat and log in. This fixes it most of the time.",
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "2. If it still fails, tap the login button repeatedly. This usually covers the next chunk.",
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "3. If it still fails, clear Snapchat's data, disable any VPN, and log in again.",
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "For rooted users:",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Reopen Snapchat and log in. If it still fails, disable PurrfectSnap in LSPosed, log in, then re-enable PurrfectSnap.",
|
||||
style = bodyStyle,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (isGenerating) return@launch
|
||||
@@ -193,7 +80,11 @@ class MappingsScreen : SetupScreen() {
|
||||
|
||||
if (warnings.isNotEmpty()) {
|
||||
isGenerating = false
|
||||
infoText = "${warnings.size} warning(s) occurred while generating mappings:\n\n${warnings.joinToString("\n")}".also {
|
||||
infoText = translation.format(
|
||||
"setup.mappings.warnings_info",
|
||||
"count" to warnings.size.toString(),
|
||||
"warnings" to warnings.joinToString("\n")
|
||||
).also {
|
||||
context.log.warn(it)
|
||||
}
|
||||
return@launch
|
||||
@@ -216,7 +107,7 @@ class MappingsScreen : SetupScreen() {
|
||||
subtitle = null
|
||||
)
|
||||
DialogText(
|
||||
text = "This only takes a moment. Keep the app open while magic happens!"
|
||||
text = translation["setup.mappings.progress_hint"]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
|
||||
@@ -71,6 +71,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.setup.patch.LSPatch
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
@@ -93,7 +94,8 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val logs = remember { mutableStateListOf("Auto Patcher is ready.") }
|
||||
val translation = context.translation
|
||||
val logs = remember { mutableStateListOf(translation["setup.patch.ready_log"]) }
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var progress by remember { mutableFloatStateOf(-1f) }
|
||||
@@ -146,7 +148,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
repeat(80) {
|
||||
if (isSnapchatInstalledAfter(patchStartedAt)) {
|
||||
installVerified = true
|
||||
pushLog("Snapchat install confirmed. You're cleared to continue.")
|
||||
pushLog(translation["setup.patch.install_confirmed_log"])
|
||||
return@launch
|
||||
}
|
||||
delay(1200)
|
||||
@@ -179,13 +181,24 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
|
||||
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
|
||||
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
|
||||
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.download_recommended_status",
|
||||
"version" to latestApk.tagName
|
||||
)
|
||||
)
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
|
||||
?: context.androidContext.cacheDir
|
||||
val cacheDir = listOfNotNull(
|
||||
context.activity?.externalCacheDir,
|
||||
context.androidContext.externalCacheDir,
|
||||
context.androidContext.cacheDir
|
||||
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
|
||||
val outputFile = File(cacheDir, latestApk.apkName)
|
||||
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
|
||||
return@withContext null
|
||||
}
|
||||
outputFile.outputStream().use { output ->
|
||||
response.body?.byteStream()?.use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
@@ -217,22 +230,27 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
downloadFinished = false
|
||||
patchStartedAt = System.currentTimeMillis()
|
||||
logs.clear()
|
||||
pushLog("Starting Auto Patcher for recommended Snapchat version.")
|
||||
pushLog(translation["setup.patch.starting_log"])
|
||||
runCatching {
|
||||
if (isSnapchatInstalled()) {
|
||||
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then start Auto Patcher again.")
|
||||
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
|
||||
pushStatus(translation["setup.patch.uninstall_prompt_status"])
|
||||
throw IllegalStateException(translation["setup.patch.uninstall_prompt_error"])
|
||||
}
|
||||
val modulePath = context.androidContext.packageManager.getPackageInfo(
|
||||
context.androidContext.packageName, 0
|
||||
).applicationInfo?.sourceDir ?: throw IllegalStateException("Module apk not found")
|
||||
pushStatus("Fetching recommended Snapchat APK...")
|
||||
).applicationInfo?.sourceDir ?: throw IllegalStateException(translation["setup.patch.module_apk_not_found_error"])
|
||||
pushStatus(translation["setup.patch.fetching_apk_status"])
|
||||
val downloaded = downloadSnapchatFromAutoPatchServer()
|
||||
?: throw IllegalStateException("Download failed")
|
||||
?: throw IllegalStateException(translation["setup.patch.download_failed_error"])
|
||||
downloadedApkPath = downloaded.absolutePath
|
||||
pushStatus("Download completed: ${downloaded.name}")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.download_completed_status",
|
||||
"fileName" to downloaded.name
|
||||
)
|
||||
)
|
||||
downloadFinished = true
|
||||
pushStatus("Starting patch powered by Jingmatrix Lspatch")
|
||||
pushStatus(translation["setup.patch.starting_patch_status"])
|
||||
val lsPatch = LSPatch(
|
||||
context.androidContext,
|
||||
mapOf(context.androidContext.packageName to File(modulePath)),
|
||||
@@ -241,16 +259,22 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
)
|
||||
val outputs = withContext(Dispatchers.IO) { lsPatch.patchSplits(listOf(downloaded)) }
|
||||
val patched = outputs["base.apk"] ?: outputs.values.firstOrNull()
|
||||
?: throw IllegalStateException("Patched apk not produced")
|
||||
?: throw IllegalStateException(translation["setup.patch.patched_not_produced_error"])
|
||||
patchedApkPath = patched.absolutePath
|
||||
pushStatus("Patched build ready. Install to finish.")
|
||||
pushStatus(translation["setup.patch.patched_ready_status"])
|
||||
}.onFailure {
|
||||
val message = it.message ?: it.toString()
|
||||
error = it.message ?: it.toString()
|
||||
it.stackTraceToString()
|
||||
.lineSequence()
|
||||
.filter { line -> line.isNotBlank() }
|
||||
.forEach { line -> pushLog(line) }
|
||||
pushStatus("Failed: ${it.message}")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.patch.failed_status",
|
||||
"message" to message
|
||||
)
|
||||
)
|
||||
}
|
||||
isRunning = false
|
||||
progress = -1f
|
||||
@@ -276,7 +300,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
fun markAlreadyInstalled() {
|
||||
installRequested = false
|
||||
installVerified = true
|
||||
pushLog("Marked as installed manually. You're cleared to continue.")
|
||||
pushLog(translation["setup.patch.mark_installed_log"])
|
||||
}
|
||||
|
||||
val accent = remember {
|
||||
@@ -291,10 +315,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
if (showIssuesDialog) {
|
||||
AestheticDialog(
|
||||
onDismissRequest = { showIssuesDialog = false },
|
||||
title = "Facing issues?",
|
||||
title = translation["setup.patch.issues_title"],
|
||||
text = "",
|
||||
icon = Icons.Filled.Info,
|
||||
confirmButtonText = "Got it",
|
||||
confirmButtonText = translation["setup.patch.issues_confirm"],
|
||||
onConfirm = { showIssuesDialog = false },
|
||||
showCloseButton = false,
|
||||
customContent = {
|
||||
@@ -310,36 +334,36 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "How to fix installation errors",
|
||||
text = translation["setup.patch.issues_heading"],
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "Issue: App cannot be installed because it conflicts with an existing package.",
|
||||
text = translation["setup.patch.issues_conflict_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "Fix: Download Snapchat from the Play Store and uninstall it without keeping data. Run Auto Patcher again. If it still does not work, run:",
|
||||
text = translation["setup.patch.issues_conflict_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "adb uninstall com.snapchat.android",
|
||||
text = translation["setup.patch.issues_adb_command"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start,
|
||||
softWrap = false,
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState())
|
||||
)
|
||||
Text(
|
||||
text = "Issue: App not installed because the package appears to be invalid.",
|
||||
text = translation["setup.patch.issues_invalid_issue"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
Text(
|
||||
text = "Fix: Download and install JingMatrix LSPatch, then patch Snapchat 13.64.0.52 in Integrated mode. Select Embed Modules and embed the PurrfectSnap APK. Then choose Skip auto setup during PurrfectSnap setup to skip Auto Patcher.",
|
||||
text = translation["setup.patch.issues_invalid_fix"],
|
||||
style = bodyStyle,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
@@ -350,12 +374,12 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Auto Patcher",
|
||||
title = translation["setup.patch.title"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
JingmatrixBadge(accent)
|
||||
JingmatrixBadge(accent, translation)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
@@ -386,9 +410,12 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
val isPatching = isRunning && downloadFinished && !isDownloading
|
||||
Text(
|
||||
text = when {
|
||||
isDownloading -> "Downloading Snapchat ${(progress * 100).toInt()}%"
|
||||
isPatching -> "Patching..."
|
||||
else -> "Initializing..."
|
||||
isDownloading -> translation.format(
|
||||
"setup.patch.status_downloading",
|
||||
"percent" to (progress * 100).toInt().toString()
|
||||
)
|
||||
isPatching -> translation["setup.patch.status_patching"]
|
||||
else -> translation["setup.patch.status_initializing"]
|
||||
},
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
@@ -420,9 +447,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
logs = logs,
|
||||
pulse = logPulse,
|
||||
accent = accent,
|
||||
translation = translation,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
|
||||
pushLog("Logs copied to clipboard.")
|
||||
pushLog(translation["setup.patch.logs_copied"])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -457,7 +485,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Patched APK installed",
|
||||
text = translation["setup.patch.install_success"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -466,7 +494,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
} else {
|
||||
if (patchedApk == null) {
|
||||
GradientActionButton(
|
||||
label = "Start auto patch",
|
||||
label = translation["setup.patch.start_button"],
|
||||
icon = Icons.Filled.Download,
|
||||
onClick = { startPatch() },
|
||||
enabled = !isRunning
|
||||
@@ -474,7 +502,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
if (patchedApk != null) {
|
||||
GradientActionButton(
|
||||
label = "Install patched Snapchat",
|
||||
label = translation["setup.patch.install_button"],
|
||||
icon = Icons.Filled.Verified,
|
||||
onClick = { installPatchedApk() },
|
||||
enabled = true
|
||||
@@ -503,7 +531,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = "Facing issues?",
|
||||
text = translation["setup.patch.issues_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -533,7 +561,7 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = "Already Installed?",
|
||||
text = translation["setup.patch.already_installed_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -549,7 +577,10 @@ class PatchSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JingmatrixBadge(accent: Brush) {
|
||||
private fun JingmatrixBadge(
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White.copy(alpha = 0.06f),
|
||||
@@ -575,7 +606,7 @@ private fun JingmatrixBadge(accent: Brush) {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Powered by Jingmatrix Lspatch",
|
||||
text = translation["setup.patch.powered_by_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -641,6 +672,7 @@ private fun LogsPanel(
|
||||
logs: List<String>,
|
||||
pulse: Float,
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -679,7 +711,7 @@ private fun LogsPanel(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Logs",
|
||||
text = translation["setup.patch.logs_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -709,7 +741,7 @@ private fun LogsPanel(
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Copy",
|
||||
text = translation["setup.patch.copy_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
@@ -721,7 +753,10 @@ private fun LogsPanel(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
logs.forEach { line ->
|
||||
Text(
|
||||
text = "- $line",
|
||||
text = translation.format(
|
||||
"setup.patch.log_line_prefix",
|
||||
"line" to line
|
||||
),
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
|
||||
@@ -73,9 +73,9 @@ class PermissionsScreen : SetupScreen() {
|
||||
|
||||
private fun descriptionFor(key: String): String {
|
||||
return when (key) {
|
||||
"notification_access" -> "Alerts you the second downloads finish."
|
||||
"battery_optimization" -> "Keeps background tasks alive without being killed."
|
||||
"display_over_other_apps" -> "Enables floating overlays while you are in Snapchat."
|
||||
"notification_access" -> context.translation["setup.permissions.notification_access_description"]
|
||||
"battery_optimization" -> context.translation["setup.permissions.battery_optimization_description"]
|
||||
"display_over_other_apps" -> context.translation["setup.permissions.display_over_other_apps_description"]
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -178,7 +178,7 @@ class PermissionsScreen : SetupScreen() {
|
||||
modifier = Modifier.padding(end = 6.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Granted",
|
||||
text = context.translation["setup.permissions.granted_label"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ class PickLanguageScreen : SetupScreen() {
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Current selection",
|
||||
text = context.translation["setup.pick_language.current_selection"],
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
@@ -180,9 +180,9 @@ class PickLanguageScreen : SetupScreen() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text(text = "Browse languages")
|
||||
Text(text = context.translation["setup.pick_language.browse_languages"])
|
||||
}
|
||||
DialogText(text = "You can change this anytime from PurrfectSnap settings.")
|
||||
DialogText(text = context.translation["setup.pick_language.change_anytime_hint"])
|
||||
}
|
||||
|
||||
if (isDialog) {
|
||||
@@ -196,7 +196,7 @@ class PickLanguageScreen : SetupScreen() {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
StepTitle(
|
||||
title = "Available Languages",
|
||||
title = context.translation["setup.pick_language.available_languages"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
|
||||
@@ -66,6 +66,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
|
||||
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
|
||||
@@ -86,7 +87,8 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val logs = remember { mutableStateListOf("Snapchat installer is ready.") }
|
||||
val translation = context.translation
|
||||
val logs = remember { mutableStateListOf(translation["setup.root_install.ready_log"]) }
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var progress by remember { mutableFloatStateOf(-1f) }
|
||||
@@ -136,7 +138,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
repeat(80) {
|
||||
if (isSnapchatInstalledAfter(downloadStartedAt)) {
|
||||
installVerified = true
|
||||
pushLog("Snapchat install confirmed. You're cleared to continue.")
|
||||
pushLog(translation["setup.root_install.install_confirmed_log"])
|
||||
return@launch
|
||||
}
|
||||
delay(1200)
|
||||
@@ -168,13 +170,24 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
|
||||
suspend fun downloadSnapchatFromAutoPatchServer(): File? = withContext(Dispatchers.IO) {
|
||||
val latestApk = autoPatchServer.fetchLatestSnapchatApk() ?: return@withContext null
|
||||
pushStatus("Downloading recommended Snapchat version (${latestApk.tagName})...")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.download_recommended_status",
|
||||
"version" to latestApk.tagName
|
||||
)
|
||||
)
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(latestApk.downloadUrl).build()).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext null
|
||||
val cacheDir = context.activity?.externalCacheDir ?: context.androidContext.externalCacheDir
|
||||
?: context.androidContext.cacheDir
|
||||
val cacheDir = listOfNotNull(
|
||||
context.activity?.externalCacheDir,
|
||||
context.androidContext.externalCacheDir,
|
||||
context.androidContext.cacheDir
|
||||
).firstOrNull { it.exists() || it.mkdirs() } ?: return@withContext null
|
||||
val outputFile = File(cacheDir, latestApk.apkName)
|
||||
if (outputFile.parentFile?.exists() == false && outputFile.parentFile?.mkdirs() == false) {
|
||||
return@withContext null
|
||||
}
|
||||
outputFile.outputStream().use { output ->
|
||||
response.body?.byteStream()?.use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
@@ -212,7 +225,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
fun markAlreadyInstalled() {
|
||||
installRequested = false
|
||||
installVerified = true
|
||||
pushLog("Marked as installed manually. You're cleared to continue.")
|
||||
pushLog(translation["setup.root_install.mark_installed_log"])
|
||||
}
|
||||
|
||||
fun startDownloadAndInstall() {
|
||||
@@ -227,27 +240,38 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
downloadFinished = false
|
||||
downloadStartedAt = System.currentTimeMillis()
|
||||
logs.clear()
|
||||
pushLog("Starting Snapchat download for rooted install.")
|
||||
pushLog(translation["setup.root_install.start_download_log"])
|
||||
runCatching {
|
||||
if (isSnapchatInstalled()) {
|
||||
pushStatus("Snapchat is installed. Please uninstall it first (don't keep data), then try again.")
|
||||
throw IllegalStateException("Snapchat still installed. Uninstall it first, to continue.")
|
||||
pushStatus(translation["setup.root_install.uninstall_prompt_status"])
|
||||
throw IllegalStateException(translation["setup.root_install.uninstall_prompt_error"])
|
||||
}
|
||||
pushStatus("Fetching recommended Snapchat APK...")
|
||||
pushStatus(translation["setup.root_install.fetching_apk_status"])
|
||||
val downloaded = downloadSnapchatFromAutoPatchServer()
|
||||
?: throw IllegalStateException("Download failed")
|
||||
?: throw IllegalStateException(translation["setup.root_install.download_failed_error"])
|
||||
downloadedApkPath = downloaded.absolutePath
|
||||
pushStatus("Download completed: ${downloaded.name}")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.download_completed_status",
|
||||
"fileName" to downloaded.name
|
||||
)
|
||||
)
|
||||
downloadFinished = true
|
||||
pushStatus("Launching installer...")
|
||||
pushStatus(translation["setup.root_install.launching_installer_status"])
|
||||
installDownloadedApk()
|
||||
}.onFailure {
|
||||
val message = it.message ?: it.toString()
|
||||
error = it.message ?: it.toString()
|
||||
it.stackTraceToString()
|
||||
.lineSequence()
|
||||
.filter { line -> line.isNotBlank() }
|
||||
.forEach { line -> pushLog(line) }
|
||||
pushStatus("Failed: ${it.message}")
|
||||
pushStatus(
|
||||
translation.format(
|
||||
"setup.root_install.failed_status",
|
||||
"message" to message
|
||||
)
|
||||
)
|
||||
}
|
||||
isRunning = false
|
||||
progress = -1f
|
||||
@@ -265,7 +289,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = "Snapchat Installer",
|
||||
title = translation["setup.root_install.title"],
|
||||
subtitle = null,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center
|
||||
@@ -299,9 +323,12 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
val isDownloading = progress >= 0f
|
||||
Text(
|
||||
text = if (isDownloading) {
|
||||
"Downloading Snapchat ${(progress * 100).toInt()}%"
|
||||
translation.format(
|
||||
"setup.root_install.status_downloading",
|
||||
"percent" to (progress * 100).toInt().toString()
|
||||
)
|
||||
} else {
|
||||
"Preparing installer..."
|
||||
translation["setup.root_install.status_preparing"]
|
||||
},
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontWeight = FontWeight.Medium
|
||||
@@ -333,9 +360,10 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
logs = logs,
|
||||
pulse = logPulse,
|
||||
accent = accent,
|
||||
translation = translation,
|
||||
onCopy = {
|
||||
clipboard.setText(AnnotatedString(logs.joinToString("\n")))
|
||||
pushLog("Logs copied to clipboard.")
|
||||
pushLog(translation["setup.root_install.logs_copied"])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -370,7 +398,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Snapchat installed",
|
||||
text = translation["setup.root_install.install_success"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -379,7 +407,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
} else {
|
||||
if (downloadedApk == null) {
|
||||
GradientActionButton(
|
||||
label = "Download Snapchat",
|
||||
label = translation["setup.root_install.download_button"],
|
||||
icon = Icons.Filled.Download,
|
||||
onClick = { startDownloadAndInstall() },
|
||||
enabled = !isRunning
|
||||
@@ -387,7 +415,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
}
|
||||
if (downloadedApk != null) {
|
||||
GradientActionButton(
|
||||
label = "Install Snapchat",
|
||||
label = translation["setup.root_install.install_button"],
|
||||
icon = Icons.Filled.Verified,
|
||||
onClick = { installDownloadedApk() },
|
||||
enabled = true
|
||||
@@ -416,7 +444,7 @@ class RootInstallSnapchatScreen : SetupScreen() {
|
||||
tint = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
Text(
|
||||
text = "Already Installed?",
|
||||
text = translation["setup.root_install.already_installed_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
@@ -489,6 +517,7 @@ private fun LogsPanel(
|
||||
logs: List<String>,
|
||||
pulse: Float,
|
||||
accent: Brush,
|
||||
translation: LocaleWrapper,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -527,7 +556,7 @@ private fun LogsPanel(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Logs",
|
||||
text = translation["setup.root_install.logs_title"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
@@ -557,7 +586,7 @@ private fun LogsPanel(
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Copy",
|
||||
text = translation["setup.root_install.copy_button"],
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
@@ -569,7 +598,10 @@ private fun LogsPanel(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
logs.forEach { line ->
|
||||
Text(
|
||||
text = "- $line",
|
||||
text = translation.format(
|
||||
"setup.root_install.log_line_prefix",
|
||||
"line" to line
|
||||
),
|
||||
color = PurrfectPalette.textPrimary,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
|
||||
@@ -52,13 +52,23 @@ class SaveFolderScreen : SetupScreen() {
|
||||
var currentFolder by remember {
|
||||
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
|
||||
}
|
||||
val readablePath = remember(currentFolder) {
|
||||
if (currentFolder.isBlank()) null
|
||||
else runCatching {
|
||||
val uri = android.net.Uri.parse(currentFolder)
|
||||
val path = uri.path ?: return@runCatching currentFolder
|
||||
if (path.contains("tree/")) {
|
||||
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
|
||||
} else currentFolder
|
||||
}.getOrElse { currentFolder }
|
||||
}
|
||||
var showNoPickerDialog by remember { mutableStateOf(false) }
|
||||
SetupCard {
|
||||
StepTitle(
|
||||
title = context.translation["setup.dialogs.save_folder"],
|
||||
subtitle = null
|
||||
)
|
||||
DialogText(text = "Please choose the location where media should be downloaded to.")
|
||||
DialogText(text = context.translation["setup.save_folder.description"])
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
@@ -99,12 +109,12 @@ class SaveFolderScreen : SetupScreen() {
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = "Destination",
|
||||
text = context.translation["setup.save_folder.destination_label"],
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
Text(
|
||||
text = if (currentFolder.isBlank()) "System default" else currentFolder,
|
||||
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
@@ -162,7 +172,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
|
||||
) {
|
||||
Text(text = "Use default location")
|
||||
Text(text = context.translation["setup.save_folder.use_default_location_button"])
|
||||
}
|
||||
|
||||
if (showNoPickerDialog) {
|
||||
@@ -204,13 +214,13 @@ class SaveFolderScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Folder picker unavailable",
|
||||
text = context.translation["setup.save_folder.no_picker_title"],
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Some cloned/dual-app environments block the system folder picker. You can continue using the system default save location, or open the app outside clone mode to select a custom folder.",
|
||||
text = context.translation["setup.save_folder.no_picker_message"],
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -230,7 +240,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
),
|
||||
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
|
||||
) {
|
||||
Text("Cancel")
|
||||
Text(context.translation["button.cancel"])
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
@@ -248,7 +258,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text("Use default")
|
||||
Text(context.translation["setup.save_folder.use_default_button"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,7 +266,7 @@ class SaveFolderScreen : SetupScreen() {
|
||||
}
|
||||
}
|
||||
DialogText(
|
||||
text = "PurrfectSnap requires Storage permissions to download and Save Media from Snapchat."
|
||||
text = context.translation["setup.save_folder.permission_hint"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
|
||||
import android.content.Context
|
||||
import android.view.MotionEvent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -29,6 +30,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -38,8 +41,8 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.config.ConfigFlag
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog as StandardDialog
|
||||
import androidx.core.net.toUri
|
||||
import com.github.skydoves.colorpicker.compose.*
|
||||
import com.google.gson.JsonParser
|
||||
@@ -56,14 +59,22 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.events.MapEventsReceiver
|
||||
import org.osmdroid.events.MapListener
|
||||
import org.osmdroid.events.ScrollEvent
|
||||
import org.osmdroid.events.ZoomEvent
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.MapEventsOverlay
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import java.io.File
|
||||
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
|
||||
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
|
||||
|
||||
|
||||
class AlertDialogs(
|
||||
@@ -208,29 +219,40 @@ class AlertDialogs(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun UniqueSelectionDialog(property: PropertyPair<*>) {
|
||||
val disabledKey = property.key.params.disabledKey
|
||||
val noDisable = property.key.params.flags.contains(ConfigFlag.NO_DISABLE_KEY)
|
||||
val keys = (property.value.defaultValues as List<String>).toMutableList().apply {
|
||||
val disabledEntry = disabledKey ?: "null"
|
||||
remove(disabledEntry)
|
||||
add(0, disabledEntry)
|
||||
if (disabledKey == null) {
|
||||
if (noDisable) {
|
||||
disabledKey?.let { remove(it) }
|
||||
remove("null")
|
||||
add(0, "null")
|
||||
} else {
|
||||
val disabledEntry = disabledKey ?: "null"
|
||||
remove(disabledEntry)
|
||||
add(0, disabledEntry)
|
||||
if (disabledKey == null) {
|
||||
remove("null")
|
||||
add(0, "null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedValue by remember {
|
||||
mutableStateOf(property.value.getNullable()?.toString() ?: (disabledKey ?: "null"))
|
||||
val currentValue = property.value.getNullable()?.toString()
|
||||
mutableStateOf(currentValue ?: if (noDisable) (keys.firstOrNull().orEmpty()) else (disabledKey ?: "null"))
|
||||
}
|
||||
|
||||
DefaultDialogCard {
|
||||
keys.forEachIndexed { index, item ->
|
||||
fun select() {
|
||||
selectedValue = item
|
||||
if (disabledKey != null && item == disabledKey) {
|
||||
if (!noDisable && disabledKey != null && item == disabledKey) {
|
||||
property.value.setAny(disabledKey)
|
||||
return
|
||||
}
|
||||
property.value.setAny(if (disabledKey == null && index == 0) null else item)
|
||||
if (!noDisable && disabledKey == null && index == 0) {
|
||||
property.value.setAny(null)
|
||||
return
|
||||
}
|
||||
property.value.setAny(item)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -314,7 +336,7 @@ class AlertDialogs(
|
||||
Button(
|
||||
onClick = {
|
||||
if (fieldValue.text.isNotEmpty() && property.key.params.inputCheck?.invoke(fieldValue.text) == false) {
|
||||
Toast.makeText(context, "Invalid input! Make sure you entered a valid value.", Toast.LENGTH_SHORT).show() //TODO: i18n
|
||||
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
|
||||
return@Button
|
||||
}
|
||||
|
||||
@@ -485,7 +507,7 @@ class AlertDialogs(
|
||||
currentColor = null
|
||||
}
|
||||
},
|
||||
label = { Text(text = "Hex Color") },
|
||||
label = { Text(text = translation["dialogs.hex_color_label"]) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
@@ -580,6 +602,8 @@ class AlertDialogs(
|
||||
property: PropertyPair<*>,
|
||||
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
|
||||
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
|
||||
locationSearchProvider: String = "osm",
|
||||
googleMapsApiKey: String = "",
|
||||
saveCoordinates: (() -> Unit)? = null,
|
||||
dismiss: () -> Unit = {}
|
||||
) {
|
||||
@@ -590,6 +614,13 @@ class AlertDialogs(
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
fun dismissKeyboard() {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
|
||||
mapView.value = remember {
|
||||
Configuration.getInstance().apply {
|
||||
@@ -599,7 +630,20 @@ class AlertDialogs(
|
||||
MapView(context).apply {
|
||||
setMultiTouchControls(true)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
setTileSource(TileSourceFactory.MAPNIK)
|
||||
val tileSource = if (locationSearchProvider == "google_maps") {
|
||||
object : OnlineTileSourceBase(
|
||||
"GoogleMaps",
|
||||
0, 19, 256, ".png",
|
||||
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
|
||||
) {
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TileSourceFactory.MAPNIK
|
||||
}
|
||||
setTileSource(tileSource)
|
||||
|
||||
val startPoint = GeoPoint(coordinates.first, coordinates.second)
|
||||
controller.setZoom(10.0)
|
||||
@@ -613,12 +657,37 @@ class AlertDialogs(
|
||||
|
||||
overlays.add(object: Overlay() {
|
||||
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
|
||||
mapView.invalidate()
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
|
||||
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun longPressHelper(p: GeoPoint?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
addMapListener(object : MapListener {
|
||||
override fun onScroll(event: ScrollEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onZoom(event: ZoomEvent?): Boolean {
|
||||
dismissKeyboard()
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
overlays.add(marker.value)
|
||||
}
|
||||
}
|
||||
@@ -673,29 +742,70 @@ class AlertDialogs(
|
||||
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
val resultsScrollState = rememberScrollState()
|
||||
|
||||
suspend fun search() {
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
|
||||
.header("User-Agent", Constants.USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@use
|
||||
}
|
||||
BackHandler {
|
||||
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
|
||||
if (shouldDismissKeyboard) {
|
||||
dismissKeyboard()
|
||||
locationName = ""
|
||||
addressResults = emptyList()
|
||||
searchJob?.cancel()
|
||||
searchJob = null
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
suspend fun search() {
|
||||
if (locationSearchProvider == "google_maps") {
|
||||
// Google Maps Search
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
|
||||
.appendQueryParameter("address", locationName)
|
||||
.appendQueryParameter("key", googleMapsApiKey)
|
||||
.build().toString())
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
|
||||
if (jsonResponse.has("results")) {
|
||||
val results = jsonResponse.getAsJsonArray("results")
|
||||
addressResults = results.take(5).map { jsonElement ->
|
||||
val result = jsonElement.asJsonObject
|
||||
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
|
||||
Triple(
|
||||
result.get("formatted_address").asString,
|
||||
geometry.get("lat").asString,
|
||||
geometry.get("lng").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// OSM Nominatim Search (Existing Logic)
|
||||
okHttpClient.newCall(Request.Builder()
|
||||
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
|
||||
.appendQueryParameter("q", locationName)
|
||||
.appendQueryParameter("format", "jsonv2")
|
||||
.build().toString())
|
||||
.header("User-Agent", Constants.OSM_USER_AGENT)
|
||||
.build()
|
||||
).await().use { response ->
|
||||
if (!response.isSuccessful) return@use
|
||||
runCatching {
|
||||
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
|
||||
addressResults = body.take(5).map { jsonElement ->
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
Triple(
|
||||
jsonObject.get("display_name").asString,
|
||||
jsonObject.get("lat").asString,
|
||||
jsonObject.get("lon").asString
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchJob = null
|
||||
}
|
||||
|
||||
@@ -759,7 +869,7 @@ class AlertDialogs(
|
||||
color = Color.White
|
||||
)
|
||||
Text(
|
||||
text = "Search or tap on the map",
|
||||
text = betterLocationTranslation["search_or_tap_map_hint"],
|
||||
fontSize = 12.sp,
|
||||
color = PurrfectPalette.textSecondary
|
||||
)
|
||||
@@ -813,11 +923,11 @@ class AlertDialogs(
|
||||
search()
|
||||
}
|
||||
},
|
||||
placeholder = { Text(text = "Search location...") },
|
||||
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = "Search",
|
||||
contentDescription = betterLocationTranslation["search_icon_description"],
|
||||
tint = Color.White
|
||||
)
|
||||
},
|
||||
@@ -889,6 +999,7 @@ class AlertDialogs(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
dismissKeyboard()
|
||||
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
|
||||
mapView.value?.controller?.setCenter(marker.value?.position)
|
||||
mapView.value?.invalidate()
|
||||
@@ -937,7 +1048,7 @@ class AlertDialogs(
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Searching...",
|
||||
text = betterLocationTranslation["searching_label"],
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White
|
||||
)
|
||||
@@ -1036,7 +1147,7 @@ class AlertDialogs(
|
||||
val lat = remember { mutableStateOf(coordinates.first.toString()) }
|
||||
val lon = remember { mutableStateOf(coordinates.second.toString()) }
|
||||
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = {
|
||||
customCoordinatesDialog = false
|
||||
},
|
||||
@@ -1085,7 +1196,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lat.value,
|
||||
onValueChange = { lat.value = it },
|
||||
label = { Text(text = "Latitude") },
|
||||
label = { Text(text = translation["latitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1105,7 +1216,7 @@ class AlertDialogs(
|
||||
.padding(horizontal = 6.dp),
|
||||
value = lon.value,
|
||||
onValueChange = { lon.value = it },
|
||||
label = { Text(text = "Longitude") },
|
||||
label = { Text(text = translation["longitude_dialog_hint"]) },
|
||||
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -1341,7 +1452,7 @@ class AlertDialogs(
|
||||
|
||||
// Add/Edit message dialog
|
||||
if (showAddDialog) {
|
||||
Dialog(
|
||||
StandardDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Returns true if the user has disabled animator duration scale at the system level.
|
||||
* This respects Accessibility/Developer settings where motion is reduced or disabled.
|
||||
*/
|
||||
fun prefersReducedMotion(context: Context): Boolean {
|
||||
return runCatching {
|
||||
@@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean {
|
||||
|
||||
@Composable
|
||||
fun rememberPrefersReducedMotion(): Boolean {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val context = LocalContext.current
|
||||
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
|
||||
// One-shot read; if you need to observe changes live, add a ContentObserver.
|
||||
LaunchedEffect(Unit) {
|
||||
state.value = prefersReducedMotion(context)
|
||||
}
|
||||
@@ -47,18 +35,34 @@ fun rememberPrefersReducedMotion(): Boolean {
|
||||
}
|
||||
|
||||
object Motion {
|
||||
/**
|
||||
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
|
||||
*/
|
||||
const val HEADER_MORPH_THRESHOLD = 300f
|
||||
|
||||
/**
|
||||
* Milliseconds per pixel for marquee scrolling speed.
|
||||
*/
|
||||
const val MARQUEE_CADENCE = 20
|
||||
|
||||
// 1. High-end Spring Physics for "Alive" UI
|
||||
val springDynamic = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
|
||||
fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Int> {
|
||||
val reduced = rememberPrefersReducedMotion()
|
||||
val d = if (reduced) 0 else durationMillis
|
||||
return tween<Int>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
|
||||
fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Float> {
|
||||
val reduced = rememberPrefersReducedMotion()
|
||||
val d = if (reduced) 0 else durationMillis
|
||||
return tween<Float>(durationMillis = d, easing = easing)
|
||||
return tween(durationMillis = d, easing = easing)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -68,23 +72,23 @@ object Motion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
|
||||
* Pass the same [interactionSource] into the clickable component to synchronize state.
|
||||
* Kinetic scale-down on press using spring physics.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.scaleOnPress(
|
||||
interactionSource: InteractionSource,
|
||||
enabled: Boolean = true,
|
||||
scaleDown: Float = 0.98f
|
||||
scaleDown: Float = 0.96f
|
||||
): Modifier {
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val target = if (pressed) scaleDown else 1f
|
||||
val spec = Motion.tweenFloatSpec(150)
|
||||
val animated by androidx.compose.animation.core.animateFloatAsState(
|
||||
|
||||
val animated by animateFloatAsState(
|
||||
targetValue = target,
|
||||
animationSpec = spec,
|
||||
animationSpec = Motion.springDynamic,
|
||||
label = "pressScale"
|
||||
)
|
||||
|
||||
return this.then(Modifier.graphicsLayer {
|
||||
scaleX = animated
|
||||
scaleY = animated
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A specialized modifier that tracks the height of the header (FloatingTopBar)
|
||||
* and provides it to the caller. This eliminates repetitive onGloballyPositioned
|
||||
* logic across all sub-pages.
|
||||
*
|
||||
* @param onHeightChanged Callback triggered when the header height is measured.
|
||||
*/
|
||||
fun Modifier.headerHeightTracker(
|
||||
onHeightChanged: (Dp) -> Unit
|
||||
): Modifier = composed {
|
||||
val density = LocalDensity.current
|
||||
this.onGloballyPositioned { coordinates ->
|
||||
val heightDp = with(density) { coordinates.size.height.toDp() }
|
||||
onHeightChanged(heightDp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package me.eternal.purrfectsnap.ui.util
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
|
||||
/**
|
||||
* A premium marquee text component that supports continuous looping.
|
||||
* Optimized using TextMeasurer for robust width calculation.
|
||||
*/
|
||||
@Composable
|
||||
fun PurrfectMarqueeText(
|
||||
text: String,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = Color.White,
|
||||
textAlign: TextAlign = TextAlign.Center,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
delayMillis: Int = 1500,
|
||||
velocity: Dp = 30.dp,
|
||||
maxLines: Int = 1,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.clipToBounds(),
|
||||
contentAlignment = contentAlignment
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
textAlign = textAlign,
|
||||
maxLines = maxLines,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (enabled) {
|
||||
Modifier.basicMarquee(
|
||||
iterations = Int.MAX_VALUE,
|
||||
repeatDelayMillis = delayMillis,
|
||||
initialDelayMillis = delayMillis,
|
||||
velocity = velocity
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 27 KiB |
BIN
banner.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
@@ -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.1.2").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("252").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.8").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("306").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)}"
|
||||
)
|
||||
|
||||
@@ -1,3 +1,186 @@
|
||||
## v1.5.8
|
||||
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
|
||||
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)
|
||||
- Fix: Optimized 1MB socket buffers for high-speed throughput which should upload the video/snap faster then before(tq to Kaladin)
|
||||
- New: Aphelion Task page went full layout overhaul with segmented "Active" and "Scheduled" tab views(tq to Kaladin)
|
||||
- FixL Auto-Open Status Card: Restored real-time visibility of processed snaps and queue status(tq to Kaladin)
|
||||
- New: Smart trickle which has background processing with resource awareness (Paused/Trickle/Normal states)(tq to Kaladin)
|
||||
- New: Four different states for auto open processing so that the device can be usable with auto open in the background(tq to Kaladin)
|
||||
- Fix: memory hogging of the auto open queues, now the device should run much faster without any lag or stuttures(tq to Kaladin)
|
||||
- Fix: "dot in username issue" with the implementation of unique incrementing ((1).jpg), where previously it was overwriting the previous file(tq to Kaladin)
|
||||
- Fix: Lock Indicator for the snaps, which should now accurately be working on all versions(tq to Kaladin)
|
||||
- Fix: Pressing back while keyboard is open in spoofed coordinates would close the dialog
|
||||
- Fix: Not able to scroll in the "Can't Login?" dialog
|
||||
|
||||
## v1.5.6
|
||||
- New: Add "Can't login" hint directly in the login screen
|
||||
|
||||
## v1.5.5
|
||||
- Fix: Custom Emoji now works for all devices!
|
||||
- New: Story preview in story batch download dialog
|
||||
- Fix: Auto Skip Stories getting stuck
|
||||
- Fix: Stories ending up saving in .dat format
|
||||
|
||||
## v1.5.4
|
||||
- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap
|
||||
- Fix: Spoof Coordinates Title
|
||||
- New: Changelogs feature
|
||||
|
||||
## 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)
|
||||
- Fix: Audio note send override(tq to RSR)
|
||||
- Fix: Improve PurrAura Active indicator(tq to RSR)
|
||||
- Fix: Prevent Story Rewatch(tq to Brandon)
|
||||
- Fix: Location search(tq to Brandon)
|
||||
- Fix: story download & decryption(tq to Hazelnut)
|
||||
- New: Add confirmation dialogs for Reset PurrfectSnap option
|
||||
- New: Google Maps Integration(tq to Brandon)
|
||||
- Fix: support more translation keys
|
||||
- Fix: several inconsistencies
|
||||
- Fix: add proper support for scripts
|
||||
- New: Swap wiki with new website
|
||||
- Fix: Custom emoji is now stable!
|
||||
- Fix: Poor Call recorder quality
|
||||
- New: order feature for chat exports
|
||||
- Fix: Gemini Auto Reply(tq to RSR)
|
||||
- New: Spotlight Creator Info(tq to RSR)
|
||||
|
||||
## v1.2.6
|
||||
- Fix: All translations are now high quality and up to date
|
||||
|
||||
## v1.2.5
|
||||
- Fix: Prevent snap replay/missed audio voice call(tq to RSR)
|
||||
- New: Call Recording UI(tq to RSR)
|
||||
- Fix: weird Search bar behaviour in overlay
|
||||
- New: wrong architecture detector
|
||||
- Fix: Arabic Translation(tq to RSR)
|
||||
- New: Fresh look of PurrAura Indicator(tq to RSR)
|
||||
- Fix: no such directory auto patcher
|
||||
- New: Auto Mapper for Manage Friend List(tq to RSR)
|
||||
- Fix: remove audio note duration & transcription(tq to RSR)
|
||||
- Fix: Missing translations
|
||||
- New: Chat Wallpaper download button UI
|
||||
|
||||
## v1.2.0
|
||||
- Fix: Prevent snap replay/missed audio voice call(thanks to RSR)
|
||||
- Fix: Various messaging fixes for newer snap versions(thanks to RSR)
|
||||
- New: Announcements feature(directly view important announcements right through the app!)
|
||||
- Fix: Missing about page
|
||||
- Fix: incorrect snap version instructions
|
||||
|
||||
## v1.1.2
|
||||
- New: Chat Wallpaper Download(upstream commit)
|
||||
- Fix: mappings error
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||