308 Commits
v1.2.6 ... dev

Author SHA1 Message Date
root
cdbcd20d65 Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features) 2026-05-30 13:37:30 +02:00
ᴋᴀʟᴀᴅɪɴ
79a1114069 v1.7.1 2026-04-27 03:32:36 +05:30
ᴋᴀʟᴀᴅɪɴ
d724e5c615 Fix: Auto-Open Ghost Notification & Social page friends selection bug (#145) 2026-04-27 03:21:33 +05:30
ᴋᴀʟᴀᴅɪɴ
73225b7151 v1.7.0 2026-04-26 14:59:37 +05:30
ᴋᴀʟᴀᴅɪɴ
546c9e1410 Merge pull request #143 from Kaladin
Features, Stability and Bug fixes.
2026-04-26 05:50:59 +05:30
DarkKnight2122
c0c395b100 FFmpeg stability fixes 2026-04-26 05:16:02 +05:30
DarkKnight2122
beede425df Sync(Feat): AI & showcase and Spoof Followers 2026-04-26 04:18:12 +05:30
RSR/
ed66ae2998 feat: Spoof Followers Count by RSR (#142) 2026-04-25 18:59:46 +05:30
DarkKnight2122
9934bd2f23 Update checker refactor 2026-04-25 17:58:28 +05:30
eternal
cb2c0dded9 feat: AI & showcase 2026-04-25 14:42:47 +05:30
DarkKnight2122
d4a5a60cc1 Manager app stutters and lag fixes 2026-04-25 09:51:16 +05:30
DarkKnight2122
cbe9754fba Disaperaing chats via adblock fix 2026-04-25 07:01:54 +05:30
DarkKnight2122
f3fb9667d8 Chat Preview text bug fix 2026-04-25 05:57:41 +05:30
DarkKnight2122
347115f8d3 Snapchat Plus bug fixes 2026-04-25 04:57:36 +05:30
DarkKnight2122
d19257869b Spoof Backup bug fixes 2026-04-25 04:21:32 +05:30
DarkKnight2122
5978717065 Social Tab bug fixes 2026-04-24 23:06:21 +05:30
DarkKnight2122
c988c489e7 Performance Mode bug fixes 2026-04-24 19:30:09 +05:30
DarkKnight2122
0e549a1565 Revert: Performance Mode PR #126 2026-04-24 15:02:26 +05:30
DarkKnight2122
2be1c3258d Message indicator bug fix 2026-04-23 22:59:11 +05:30
DarkKnight2122
c13759e99a Sync upstream changes 2026-04-23 20:56:09 +05:30
DarkKnight2122
fb94a2e8a3 Continous Send bug fixes 2026-04-23 13:20:52 +05:30
Majjo
5651d48d96 debug: fix strategy E/F to use eachBuffer instead of followPath, dump snap item bytes 2026-04-21 17:11:13 +02:00
Majjo
8f8c696431 fix: resolve fallbackUserId out of scope in strategy E/F 2026-04-21 15:43:12 +02:00
Majjo
e408a505ab debug: add strategy E/F for europe-west1 batch_stories + snap descriptor hex dump 2026-04-20 22:39:52 +02:00
DarkKnight2122
335cc00dfb Unlimited Local pins bug fix 2026-04-20 18:51:15 +05:30
Majjo
ded49b8b1b debug: hex dump batch_stories responses >1KB that parse 0 stories 2026-04-20 15:17:45 +02:00
Majjo
d9593fa629 feat: add getBusinessStoryManifest capture + multi-strategy proto probe + no_match hex dump 2026-04-20 14:22:19 +02:00
DarkKnight2122
a20496329c Downloader allow duplicate and ffmpeg shortest sync fixes 2026-04-20 15:41:19 +05:30
ᴋᴀʟᴀᴅɪɴ
29f05a2d7f Merge pull request #141 from RSR
New: memories indicator and Redesign all indicators , now the lock icon "only to me" work with external media and memories
2026-04-20 13:20:52 +05:30
RSR/
ac2e39eccf New: memories indicator and Redesign all indicators 2026-04-20 05:47:21 +04:00
DarkKnight2122
a18fc8ce37 Media Downloader audio duration mapping fix 2026-04-20 04:58:23 +05:30
Majjo
cab8c69f72 feat: story debug panel, log level filters, verbose story event logging 2026-04-20 01:15:25 +02:00
DarkKnight2122
4bd13a9d09 Media Downloader stability fixes 2026-04-20 04:25:49 +05:30
Majjo
616ae0d3b3 feat: add Developer > Debug Stories config with ZIP dump 2026-04-20 00:38:02 +02:00
Majjo
c0c5503864 fix: save ZIP base media directly to gallery, avoid downloadRemoteMedia NPE 2026-04-20 00:34:44 +02:00
Majjo
98e04a81cb fix: capture story_lookup/batch_story_lookup endpoints for story media URLs 2026-04-20 00:29:57 +02:00
DarkKnight2122
61dcfd9b57 Auto open notification card bug fixes 2026-04-20 03:34:18 +05:30
Majjo
947bb399c8 ci: remove armv7 jobs, build arm64-v8a only 2026-04-19 23:54:48 +02:00
DarkKnight2122
fa5c7e0771 Hardened bridge stability with 15s idle timeout 2026-04-20 03:18:56 +05:30
DarkKnight2122
dde94aeffc Logs filtering UI bug fix 2026-04-20 02:13:35 +05:30
DarkKnight2122
4e79a22b43 UI Fixes in Friend feed 2026-04-20 02:10:07 +05:30
ᴋᴀʟᴀᴅɪɴ
5037f393f7 Merge pull request #140 from schrodingerspet
fix(downloader): download logger-backed deleted media from chat context menu
2026-04-20 01:58:55 +05:30
schrodingerspet
7d30e3b632 fix(downloader): support logger-backed deleted media in chat download menu
Add a fallback for Message Logger deleted media when the normal attachment payload is unavailable, while keeping standard download behavior unchanged.
2026-04-20 01:11:00 +05:30
ΞTΞRNAL
05b4e01d27 Merge pull request #139 from schrodingerspet/fix/message-logger-import-persistence
fix(ui): enforce Hide Friend Feed Entry on main friend feed
2026-04-20 00:04:09 +05:30
schrodingerspet
5b12844a24 fix(ui): hide selected users from the real friend feed
Use more reliable hidden-ID matching for friend-feed rows and add a fallback hide during binding to prevent row reappearance.

Keep Best Friends, send, and search behavior unchanged.
2026-04-19 23:39:21 +05:30
Majjo
e23bf443a1 fix: discard overlay segment from ZIP container when saving stories 2026-04-19 12:49:18 +02:00
ᴋᴀʟᴀᴅɪɴ
b5a31bee7b Merge pull request #138 from schrodingerspet
fix(message-logger): make backup import persistent and validated
2026-04-19 14:07:13 +05:30
ᴋᴀʟᴀᴅɪɴ
9901812db6 Merge pull request #137 from schrodingerspet
fix(bridge): prevent background DeadObjectException crash on OnePlus
2026-04-19 14:06:07 +05:30
schrodingerspet
0767289210 fix(message-logger): make backup import persistent and validated
Add a safer validated import flow in LoggerWrapper with proper failure handling and persistence across restart.

Update Message Logger import actions to report real success or failure.
2026-04-19 12:24:26 +05:30
schrodingerspet
5de180336e fix(bridge): prevent background DeadObjectException crash on OnePlus
Handle bridge binder death during service connection and remote init calls.

Add stale-reference cleanup and safer reconnect handling.
2026-04-19 11:40:07 +05:30
Aditya Akolkar
632df791bd Merge pull request #136 from schrodingerspet/fix/media-resend-duration-pr
fix: preserve queued snap duration for media resend flow
2026-04-19 11:35:20 +05:30
Aditya Akolkar
c09a5df615 Merge pull request #1 from schrodingerspet/fix/media-resend-duration-clean
fix: preserve queued snap duration for media resend flow
2026-04-19 11:17:45 +05:30
schrodingerspet
9d5504dee0 fix: preserve queued snap duration for media resend flow
Keep selected snap duration when processing queued split items and repeated sends.
2026-04-19 11:16:34 +05:30
schrodingerspet
0ad9d0a5dc Revert "fix: preserve queued snap duration for media resend flow"
This reverts commit ec6e4deae8.
2026-04-19 11:16:25 +05:30
schrodingerspet
7499b08494 Merge branch 'feat/message-logger-export-individual-chat' into dev 2026-04-19 10:55:13 +05:30
schrodingerspet
ec6e4deae8 fix: preserve queued snap duration for media resend flow
Keep selected snap duration when processing queued split items and repeated sends.
2026-04-19 10:48:38 +05:30
ᴋᴀʟᴀᴅɪɴ
839ab8232a Merge pull request #134 from schrodingerspet
feat: Add message logger export individual chat.
2026-04-19 02:27:57 +05:30
ᴋᴀʟᴀᴅɪɴ
50def2be77 Merge pull request #133 from Javalsta
Organize social tab by streak length.
2026-04-19 02:25:34 +05:30
ᴋᴀʟᴀᴅɪɴ
cf1f691762 Merge pull request #132 from RSR
fix: the 🔒 icon indicator for snaps
2026-04-19 02:23:49 +05:30
schrodingerspet
22bbadf926 feat: add message logger export individual chat 2026-04-19 01:01:38 +05:30
Majjo
760d652f5f feat: story capture API + Python downloader script 2026-04-18 21:01:07 +02:00
Majjo
22ee866205 fix: remove duplicate JS block causing SyntaxError in dashboard 2026-04-18 20:04:08 +02:00
Majjo
ce3361992c feat: full request/response inspection with detail panel, hex dump, JSON pretty-print 2026-04-18 19:13:34 +02:00
Majjo
768a3b7c09 feat: cookie-based session auth for companion server (login page, POST /login, /logout) 2026-04-18 18:12:56 +02:00
Majjo
b0715b3f27 fix: serve dashboard unauthenticated, validate token via /ping endpoint 2026-04-18 17:41:28 +02:00
Majjo
b6435f9010 fix: add language strings for companion server; fix startup logging and null safety 2026-04-18 16:57:47 +02:00
Majjo
e0431bd001 feat: add Companion Server for live log and network monitoring 2026-04-18 16:23:51 +02:00
Majjo
d86d9db508 ci: update actions to Node 24 compatible versions 2026-04-18 15:24:46 +02:00
Majjo
068b272b5e fix: skip overlay merge for stories, highlights, and spotlight 2026-04-18 15:17:12 +02:00
Javalsta
0aa1317950 add feature 2026-04-17 23:35:10 +01:00
RSR/
5a4fdd3664 Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-04-18 02:26:47 +04:00
RSR/
65086b79b3 fix: the 🔒 icon ,
It is supposed to work on all devices
2026-04-18 02:25:46 +04:00
ᴋᴀʟᴀᴅɪɴ
e201d7046c Merge pull request #129 from imCrest/dev
Sync: Latest improvements and multiple fixes by Sujal
2026-04-17 23:55:39 +05:30
ᴋᴀʟᴀᴅɪɴ
1e55a83ca0 Merge branch 'dev' into dev 2026-04-17 23:43:45 +05:30
ᴋᴀʟᴀᴅɪɴ
f95a4d8552 Merge pull request #131 from DarkKnight2122
Concurrent Downloads and Proactive Memory Management for media downloader engine
2026-04-17 23:04:00 +05:30
ᴋᴀʟᴀᴅɪɴ
ff097fbe41 Merge pull request #130 from Daplugg23
fix: resolve dropped friends list by properly handling chunked social…
2026-04-17 23:00:14 +05:30
ᴋᴀʟᴀᴅɪɴ
4b92304d84 Merge pull request #128 from imCrest/patch-1
Refactor SPOTLIGHT_5TH_TAB_ENABLED property override
2026-04-17 22:46:14 +05:30
imCrest
9c226d1bb9 Sync: Update from upstream 2026-04-17 14:03:43 +00:00
imCrest
7005fdf567 Sync: Update from upstream 2026-04-17 12:34:24 +00:00
imCrest
10a820e93e Sync: Update from upstream 2026-04-17 12:16:08 +00:00
imCrest
cd8d358fa6 Sync: Update from upstream 2026-04-17 12:03:36 +00:00
imCrest
73616f2158 Sync: Update from upstream 2026-04-17 11:25:16 +00:00
imCrest
8e1628e28c Merge pull request #15 from imCrest/imCrest/fix-disable-spotlight-feed-tab 2026-04-17 11:02:43 +00:00
imCrest
9223d2debc fix-disable-spotlight-chat-media 2026-04-17 10:13:24 +00:00
ᴋᴀʟᴀᴅɪɴ
d61eb68d8b v1.6.9 2026-04-17 01:23:19 +05:30
imCrest
3dccadc5c0 Sync upstream 2026-04-16 12:42:06 +00:00
imCrest
98f0db6a15 Harden macOS native build by clearing DYLD override vars 2026-04-15 06:17:26 +00:00
daplugg23
9fa96125b2 fix: resolve dropped friends list by properly handling chunked social snapshots
The social snapshot chunking mechanism (introduced in v1.6.2) split the friends list correctly on the BridgeClient, but the BridgeService was discarding subsequent chunks. This updates the Manager to explicitly process and combine all received chunks via the database before updating the AddFriendDialog UI.
2026-04-14 19:52:31 -05:00
imCrest
992a48fa81 Stabilize UITweaks block structure for CI Kotlin parsing 2026-04-14 22:08:57 +00:00
imCrest
360540edb4 Refactor spotlight nav filter helper to avoid parsing/side-effect issues 2026-04-14 21:57:34 +00:00
imCrest
4a17989c96 Narrow spotlight tab hiding to navigation ids only 2026-04-14 21:36:46 +00:00
imCrest
81796c8824 Refine spotlight-tab hiding to avoid friend-feed media breakage 2026-04-14 21:20:03 +00:00
imCrest
73ed1a9647 Fix disable spotlight to affect home tab without breaking chat media 2026-04-14 20:56:23 +00:00
imCrest
2faa368722 Merge pull request #5 from imCrest/imCrest/add-customizable-purchase-date-feature-7fai8g 2026-04-14 17:43:15 +00:00
imCrest
2913cfe794 Merge pull request #4 from imCrest/imCrest/add-customizable-purchase-date-feature-di5qqb
Add Snapchat Plus purchase date setting and chat-hold kill actions (UI + core)
2026-04-14 17:12:25 +00:00
Sujal Sahu
4e3ef029a1 fix(PR): Multiple fixes by Kaladin
- Auto Open Engine refactor and implemented the engine stop button in the notification card.
- Media downloader stabilization fixes including Batch download fix.
- Implemented a new log filtering menu.
- Cargo Dependency updated to latest stable versions.
- Minor bug fixes.
2026-04-14 14:59:54 +00:00
C R E S T
3e07d4c419 Refactor SPOTLIGHT_5TH_TAB_ENABLED property override 2026-04-14 19:00:20 +05:30
DarkKnight2122
83e8865266 UI fixes and stabilization 2026-04-14 18:19:17 +05:30
ΞTΞRNAL
dbf6de8a65 fix(PR): Multiple fixes by Kaladin
- Auto Open Engine refactor and implemented the engine stop button in the notification card.
- Media downloader stabilization fixes including Batch download fix.
- Implemented a new log filtering menu.
- Cargo Dependency updated to latest stable versions.
- Minor bug fixes.
2026-04-14 17:39:18 +05:30
C R E S T
6f4e746a66 Refactor custom purchase date handling in SnapchatPlus 2026-04-14 17:27:54 +05:30
C R E S T
df76d13904 Refactor snapchatPlusPurchaseDate initialization 2026-04-14 17:27:03 +05:30
C R E S T
7b7bceec80 Update settings in en_US.json
Removed chat button hold kill settings and added Snapchat Plus purchase date.
2026-04-14 17:25:29 +05:30
C R E S T
61bcb6d743 Fix missing newline at end of AlertDialogs.kt 2026-04-14 17:21:18 +05:30
C R E S T
0689cc2005 Replace ValueGlowChip with Button for purchase date 2026-04-14 17:20:22 +05:30
DarkKnight2122
279e847d00 Auto Open Engine refactor and Media Downloader stabilization. 2026-04-14 16:31:45 +05:30
C R E S T
a859d4825b Implement long click listener for chat button
Add long click listener to handle app termination based on user settings.
2026-04-14 16:09:00 +05:30
C R E S T
25dd438da2 Add terminateModuleProcess function to BridgeClient 2026-04-14 16:07:59 +05:30
C R E S T
2c9f5d45e2 Add ChatButtonHoldKill configuration options 2026-04-14 16:07:07 +05:30
C R E S T
e89628e5c2 Add chat button hold kill feature
Added chat button hold kill feature with properties.
2026-04-14 16:06:15 +05:30
C R E S T
6f0639b8e9 Add terminateModuleProcess method to BridgeInterface 2026-04-14 16:04:36 +05:30
C R E S T
ba607fb419 Add process termination functionality to BridgeService 2026-04-14 16:02:47 +05:30
C R E S T
b682ee8432 Add custom purchase date handling for SnapchatPlus 2026-04-14 15:37:20 +05:30
C R E S T
7d7488d05e Add Snapchat Plus purchase date configurations
Added custom purchase date and purchase date fields for Snapchat Plus with input validation.
2026-04-14 15:36:36 +05:30
C R E S T
4f0db150b0 Enhance Snapchat Plus with custom purchase date options
Added properties for custom purchase date in Snapchat Plus.
2026-04-14 15:35:24 +05:30
C R E S T
810afd88f7 Implement DatePickerPropertyDialog for date selection
Added a DatePickerPropertyDialog composable for selecting dates.
2026-04-14 15:34:12 +05:30
C R E S T
ed6786e65b Add handling for Snapchat Plus purchase date property 2026-04-14 15:31:56 +05:30
ΞTΞRNAL
0db1c5036e feat(PR): enhanced stealth mode by tnewman-afk
enhanced stealth-mode
2026-04-12 23:33:00 +05:30
ΞTΞRNAL
cc7539d262 fix(PR): Multiple fixes by schrodingerspet
feat(performance-mode): fix max profile lifecycle/resume corruption
2026-04-12 23:24:57 +05:30
schrodingerspet
ca37953476 revert(config): remove legacy performance profile migration 2026-04-12 23:20:00 +05:30
schrodingerspet
050dea3214 fix(native): disable OMVLL by default on macOS CI 2026-04-12 22:45:20 +05:30
schrodingerspet
724d7efb1e fix(native): add macOS NDK host-toolchain fallback for CI 2026-04-12 22:29:14 +05:30
schrodingerspet
a855983c46 fix(ci): add manager module alias to mapper 2026-04-12 21:33:47 +05:30
schrodingerspet
7dd9ada91d feat(performance-mode): fix max profile lifecycle/resume corruption 2026-04-12 16:14:51 +05:30
ΞTΞRNAL
3d48b14def New announcement!
Added a temporary fix for conversations disappearing.
2026-04-11 16:49:55 +05:30
Javalsta
6d1eb89adf attempt1 2026-04-10 23:26:21 +01:00
ΞTΞRNAL
b44558babd fix(PR): Auto Open stabilization and Spotlight/Story video download fixes by Kaladin
Auto Open stabilization and Spotlight/Story video download fixes.
2026-04-05 15:37:00 +05:30
ΞTΞRNAL
e97a37d18e v1.6.8 2026-04-05 15:35:39 +05:30
DarkKnight2122
281d55689a Spotlight/Story video download fix 2026-04-05 03:28:17 +05:30
DarkKnight2122
7475998961 Optimize background engine & sync with Notifications for instant previews 2026-04-05 00:53:56 +05:30
ΞTΞRNAL
f01b6c2f9a add fresh changelogs
Updated changelog for version 1.6.6 with multiple fixes and new features, including pre-fetch snaps toggle and disk optimization improvements.
2026-04-02 20:48:00 +05:30
ΞTΞRNAL
98f8d6ebea fix(PR): auto open notification fix by Kaladin
fix: auto open notification fix
2026-04-02 20:39:32 +05:30
DarkKnight2122
015226cb40 fix: auto open notification fix 2026-04-02 19:43:59 +05:30
ΞTΞRNAL
ad06b0dffb v1.6.6 2026-04-02 19:00:08 +05:30
ΞTΞRNAL
4b4d64e8eb fix version bump 2026-04-02 14:18:43 +05:30
ΞTΞRNAL
3eec22c615 fix(PR): Restore Auto Open stability by Kaladin
Restore Auto Open stability
2026-04-02 14:01:08 +05:30
ΞTΞRNAL
9a2e6065dc Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-04-02 13:59:43 +05:30
ΞTΞRNAL
8e00c29a59 v1.6.5 2026-04-02 13:57:36 +05:30
DarkKnight2122
9ec9517ec5 fix(core): Restore Auto Open stability 2026-04-02 06:45:13 +05:30
ΞTΞRNAL
e822fc20b4 New announcement!
Added recommendation for using Performance Mode feature in Snapchat.
2026-04-02 02:41:12 +05:30
ΞTΞRNAL
f3c794fc47 v1.6.4 2026-04-02 00:22:02 +05:30
ΞTΞRNAL
6e6d311c23 v1.6.3 2026-04-01 16:36:35 +05:30
ΞTΞRNAL
a82db6a863 update changelog for v1.6.2 2026-04-01 15:33:17 +05:30
ΞTΞRNAL
9781008b2e fix(PR): Auto Open and Video Downloader Stability fixes by Kaladin
Auto Open and Video Downloader Stability fixes
2026-04-01 14:56:55 +05:30
ΞTΞRNAL
c71e06095d v1.6.2 2026-04-01 14:55:05 +05:30
DarkKnight2122
1fdaf6d55d Auto Open stabilization 2026-04-01 05:08:58 +05:30
DarkKnight2122
8a9258b318 Video Downloader Stability 2026-03-31 17:24:58 +05:30
ΞTΞRNAL
6491288513 v1.6.1 2026-03-29 12:35:34 +05:30
ΞTΞRNAL
33664c1112 v1.6.0 2026-03-29 00:40:08 +05:30
ΞTΞRNAL
bb8e644b4c fix(PR): Select highest quality media by comparing resolution by Issac XT
Fix: Select highest quality media by comparing resolution
2026-03-28 23:53:04 +05:30
ΞTΞRNAL
21fba253b1 v1.5.9 2026-03-28 12:57:15 +05:30
Issac
9e5b318120 Fix: Select highest quality media by comparing resolution instead of assuming array order 2026-03-28 11:01:39 +05:30
ΞTΞRNAL
00c5d60b7b v1.5.8 2026-03-28 02:42:33 +05:30
ΞTΞRNAL
21cd306132 fix(PR): QOL fixes & Optimizations by Kaladin
QOL fixes & Optimizations.
2026-03-28 01:40:24 +05:30
DarkKnight2122
0d42aed0ff feat: Auto Open optimization 2026-03-28 00:22:36 +05:30
DarkKnight2122
f8fdd1893f feat: Task page layout revamp 2026-03-28 00:22:36 +05:30
DarkKnight2122
a8e2148b26 feat: Camera, Audio, Video & Network Optimizations 2026-03-28 00:22:35 +05:30
ΞTΞRNAL
1e9ad8eb2b Add v1.5.6 changelog entry 2026-03-27 01:06:17 +05:30
ΞTΞRNAL
182e7eefeb v1.5.6 2026-03-27 01:05:17 +05:30
ΞTΞRNAL
070a8ffaf7 v1.5.5 2026-03-26 21:24:28 +05:30
ΞTΞRNAL
95f98221e3 v1.5.4 2026-03-26 12:13:52 +05:30
ΞTΞRNAL
dbbc52b67f v1.5.3 2026-03-25 21:59:31 +05:30
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
ΞTΞRNAL
901e4f81b8 fix: Missing changes 2026-03-22 20:32:16 +05:30
ΞTΞRNAL
a4e86b5ab4 fix(PR): Aphelion task page layout optimization by Kaladin
Aphelion task page layout optimization
2026-03-22 20:31:25 +05:30
ΞTΞRNAL
bdc6d12739 fix: splitting issue for snaps sent through send override 2026-03-22 20:30:34 +05:30
DarkKnight2122
6c8d5297c8 Task page layout harmonization. 2026-03-22 19:43:20 +05:30
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
ΞTΞRNAL
565a8b8287 v1.4.9 2026-03-21 03:48:43 +05:30
ΞTΞRNAL
1c612ebc8e fix: force AMOLED theme for newer snap versions 2026-03-21 02:09:33 +05:30
ΞTΞRNAL
8db363a8f0 add missing changelog 2026-03-20 22:19:18 +05:30
ΞTΞRNAL
4fab5cc4ab fix(PR): Crash when using the theme button in android 11 & 12 by Kaladin
fix: Crash when using the theme button in android 11 & 12.
2026-03-20 22:18:12 +05:30
ΞTΞRNAL
a7a15702f3 v1.4.8 2026-03-20 22:16:37 +05:30
DarkKnight2122
04aaefc748 fix: optimize theme reveal fallbacks for Android 11 and 12 2026-03-20 19:22:55 +05:30
ΞTΞRNAL
58c4be44f2 v1.4.6 2026-03-20 05:05:25 +05:30
ΞTΞRNAL
3d83c3116c revert: accidental change 2026-03-20 03:00:24 +05:30
ΞTΞRNAL
2b00898355 v1.4.5 2026-03-20 02:58:58 +05:30
ΞTΞRNAL
975a9a101f fix: duplicate opera download & mark snaps as seen buttons 2026-03-20 01:40:12 +05:30
ΞTΞRNAL
793659c63b fix: friend feed menu button overlapping for group chats 2026-03-19 22:13:45 +05:30
ΞTΞRNAL
5bdc1d7f59 feat(ui): Implement theme reveal transition by Kaladin
feat(ui): Implement theme reveal transition using AGSL
2026-03-18 22:12:24 +05:30
DarkKnight2122
fc66453167 feat(ui): implement theme reveal transition using AGSL 2026-03-18 19:03:37 +05:30
ΞTΞRNAL
84a9d01d6a fix(PR): Multiple fixes by Kaladin
Aphelion theme fixes.
2026-03-18 16:56:06 +05:30
DarkKnight2122
a4b948bba3 UI: Aphelion Changelog and Setup Enhancements 2026-03-18 11:31:55 +05:30
DarkKnight2122
8c720992de UI Fixes for Aphelion and Optimizations 2026-03-18 11:31:55 +05:30
DarkKnight2122
d1fc97cee8 Aphelion Theme Harmonization across tasks page 2026-03-18 11:31:53 +05:30
ΞTΞRNAL
9df49f1bff Merge pull request #114 from RSR1337/RSR
fix: FFMpegProcessor copyright
2026-03-17 11:19:02 +05:30
RSR/
652062769e fix: FFMpegProcessor copyright 2026-03-15 03:14:59 +04:00
ΞTΞRNAL
f1eb833655 fix(PR): Multiple fixes and improvements by RSR
feat:  Video Recording Timer , Auto Skip . fix: Fake Snap Score
2026-03-15 00:28:56 +05:30
RSR/
a27753d8ae feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score 2026-03-14 20:05:59 +04:00
ΞTΞRNAL
da8a261202 feat(PR): Spoof SnapScore by RSR
RSR
2026-03-12 05:20:04 +05:30
ΞTΞRNAL
532ebfe0a7 Merge pull request #112 from particle-box/revert-110-revert-109-RSR
Revert "Revert "feat: Spoof Snap Score""
2026-03-12 05:19:12 +05:30
ΞTΞRNAL
bf7f371022 Revert "Revert "feat: Spoof Snap Score"" 2026-03-12 05:18:59 +05:30
RSR/
15c9a3fd5d Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-12 02:27:47 +04:00
RSR/
bb0ab20a5b fix 2026-03-12 02:27:33 +04:00
ΞTΞRNAL
9a507684ee Merge pull request #110 from particle-box/revert-109-RSR
Revert "feat: Spoof Snap Score"
2026-03-12 03:53:23 +05:30
ΞTΞRNAL
1fbf82b4fb Revert "feat: Spoof Snap Score" 2026-03-12 03:52:54 +05:30
ΞTΞRNAL
53696c26f4 feat(PR): Spoof SnapScore by RSR
feat: Spoof Snap Score
2026-03-12 03:47:24 +05:30
RSR/
381b190535 Merge branch 'particle-box:dev' into RSR 2026-03-12 02:12:51 +04:00
RSR/
50199b052f fix 2026-03-12 02:12:09 +04:00
particle-box
2135d66124 many fixes & improvements(v1.4.1) 2026-03-12 01:04:30 +05:30
RSR/
6948d86efc feat: Spoof Snap Score 2026-03-11 19:47:13 +04:00
ΞTΞRNAL
481e5840f0 fix overlay position by AhmedRaza 2026-03-11 11:57:12 +05:30
ΞTΞRNAL
216bb71fef feat(PR): Improved Device Spoof by RSR
Improved the spoof device feature
2026-03-11 04:57:16 +05:30
RSR/
181d19424f Improved the spoof device feature 2026-03-11 01:47:07 +04:00
ΞTΞRNAL
a4d2513138 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-10 21:02:59 +05:30
ΞTΞRNAL
9c0f14e797 fix some UI bugs 2026-03-10 21:02:25 +05:30
ΞTΞRNAL
800b40aaae add missing changelog 2026-03-10 20:40:36 +05:30
ΞTΞRNAL
9577dadd38 feat(PR): Aphelion Theme by Kaladin
Introducing Aphelion Theme
2026-03-10 19:36:31 +05:30
ΞTΞRNAL
d4a084f896 bump version to 1.4.0 2026-03-10 19:35:52 +05:30
ΞTΞRNAL
28df958197 Merge branch 'dev' into themes 2026-03-10 19:27:32 +05:30
Kaladin
9b0f336f91 Update RetroGameScreen.kt 2026-03-10 19:10:20 +05:30
DarkKnight2122
c52f60a29b Introducing Aphelion Theme 2026-03-10 18:49:31 +05:30
ΞTΞRNAL
a327786d9e feat(PR): Story Snap Batch Download by RSR
feat: Story Snap List Download
2026-03-10 18:46:54 +05:30
ΞTΞRNAL
2b0c6cae36 fix multiple issues 2026-03-10 18:43:57 +05:30
particle-box
05a383c823 fix: crash issues 2026-03-10 13:34:32 +05:30
RSR/
13cd3656fd Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR 2026-03-10 02:07:14 +04:00
RSR/
07a8d3041e feat: Download story snaps , Fix Translation 2026-03-10 02:00:20 +04:00
particle-box
5ece2ec408 fix: mark snaps as seen button for newer snap versions 2026-03-10 01:43:29 +05:30
ΞTΞRNAL
7f9b911f0b multiple fixes & improvements 2026-03-09 20:05:28 +05:30
DarkKnight2122
b1b6a4ae6f added high quality vectors 2026-03-09 02:16:53 +05:30
particle-box
eeee0c2e72 v1.3.9 2026-03-08 19:26:26 +05:30
particle-box
07d4a4f833 many fixes & improvements 2026-03-08 17:19:38 +05:30
particle-box
4adcff36e3 improve ads & metrics blocking 2026-03-08 04:47:58 +05:30
particle-box
26077a8c87 fix: translation 2026-03-07 04:41:46 +05:30
particle-box
28982aa655 v1.3.8 2026-03-07 04:33:21 +05:30
particle-box
89748cd261 multiple fixes & improvements 2026-03-07 00:00:36 +05:30
particle-box
97797127bd Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-06 01:49:04 +05:30
particle-box
23d8ff0370 fix: crash issue for some devices 2026-03-06 01:48:34 +05:30
ΞTΞRNAL
d986440f9f fix: add missing changelog 2026-03-04 15:19:44 +05:30
particle-box
fc3b1bcde1 prepare for v1.3.5 2026-03-04 15:18:25 +05:30
ΞTΞRNAL
0289f96019 Merge pull request #105 from particle-box/revert-102-dev
revert(PR): 102
2026-03-04 14:17:55 +05:30
ΞTΞRNAL
ec9ecac15c Revert "Feature: UI Modernization, Themed Icons, and Performance Optimization" 2026-03-04 14:15:32 +05:30
ΞTΞRNAL
5efccc9e78 many changes by DarkKnight2122
Feature: UI Modernization, Themed Icons, and Performance Optimization
2026-03-04 09:17:07 +05:30
ΞTΞRNAL
ae53cc1f72 many new features & fixes by RSR
- Story counter & Story Source
- fix: Media Quality Override
2026-03-04 09:13:04 +05:30
ΞTΞRNAL
5b170ebae6 Merge branch 'dev' into RSR 2026-03-04 09:11:37 +05:30
RSR/
87ca0eaa82 fix: Media Quality 2026-03-04 07:28:26 +04:00
RSR/
5496dc7eb5 feat: Story Counter , Story Source 2026-03-04 07:15:38 +04:00
particle-box
183164fd52 fix:snaps saving in chat & new look for msg edit history by AhmedRaza 2026-03-04 00:57:05 +05:30
particle-box
4987577001 fix: better location overlay position for newer snap versions 2026-03-04 00:17:32 +05:30
particle-box
c7a879fa62 fix: better location overlay crash 2026-03-03 23:55:50 +05:30
particle-box
d6e74cb207 fix: migration side effects 2026-03-03 23:37:30 +05:30
particle-box
1ccdaee879 kavaref migration 2026-03-03 21:28:18 +05:30
ΞTΞRNAL
956bdda6c0 migration from libxposed API to YukiHook 2026-03-03 19:16:14 +05:30
RSR/
1b1f68c6cf : ) 2026-02-26 06:44:29 +04:00
DarkKnight2122
b6f9c10b21 Implemented high-precision Vector Themed Icons and optimized system Updater logic, and improved the haptics manager wide. 2026-02-25 19:48:18 +05:30
RSR/
fb3f1f55c7 feat:StoryCounter
Display the number of snaps in the story
2026-02-25 05:08:14 +04:00
DarkKnight2122
f8e5d7370a Standardized sub-page layouts and enhanced system stability across all modules 2026-02-24 16:45:40 +05:30
DarkKnight2122
7623ec30c9 Optimized component animations with basicMarquee integration 2026-02-24 16:35:15 +05:30
DarkKnight2122
7be8e33cc6 Implemented morphing Infinity header 2026-02-24 16:35:15 +05:30
ΞTΞRNAL
bc252ea9a3 prepare for v1.3.2 2026-02-24 16:03:05 +05:30
particle-box
4aba9c815f multiple fixes 2026-02-24 12:51:25 +05:30
ΞTΞRNAL
05c71c7ef4 fix: checksum 2026-02-24 03:41:22 +05:30
ΞTΞRNAL
57ebadbc51 fix: checksum 2026-02-24 03:38:00 +05:30
particle-box
bdf3808526 fix: slow builds 2026-02-24 02:21:26 +05:30
particle-box
e8d1f62ff1 add translations back 2026-02-24 02:10:33 +05:30
particle-box
b64faf31ae Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-02-24 01:58:11 +05:30
particle-box
ed86916ed7 temporary removal 2026-02-24 01:57:47 +05:30
ΞTΞRNAL
5935afb6e1 fix(PR): Media file Picker and added mapper by RSR
fix MediaFilePicker on snap13.79 and added mapper
2026-02-18 11:43:03 +05:30
RSR/
1ac1542384 fix MediaFilePicker on snap13.79 and added mapper 2026-02-16 20:31:42 +04:00
ΞTΞRNAL
8eea50e745 fix: accidental change 2026-02-14 14:27:07 +05:30
ΞTΞRNAL
795bc1f96a address fdroid bot feedback
Added SHA256 checksum for Gradle distribution.
2026-02-14 14:26:06 +05:30
particle-box
0ab60d8b20 add icons and banner 2026-02-14 14:06:26 +05:30
particle-box
627abb3927 add support for frdroid 2026-02-14 14:04:17 +05:30
ΞTΞRNAL
f32a4c4a23 add buiding instructions 2026-02-14 13:39:53 +05:30
particle-box
4753d91315 update translations 2026-02-12 11:31:36 +05:30
particle-box
f1dc4429d8 revert profile downloader button 2026-02-12 09:48:54 +05:30
particle-box
e6135584d4 fix: several inconsistencies 2026-02-12 09:46:48 +05:30
ΞTΞRNAL
ea82a5d373 feat(PR): spotlight creator info by RSR
Spotlight Creator Info
2026-02-09 16:02:51 +05:30
William Boser
36aa0c52a2 Spotlight Creator Info 2026-02-09 12:10:18 +04:00
RSR/
e3008e0c9c fix 2026-02-07 19:20:39 +04:00
ΞTΞRNAL
c50327bfe9 fix(PR): story decryption by hazelnut27
Enhance media encryption handling with hybrid resolver and improved decryption methods
2026-02-07 19:14:50 +05:30
hazelnut27
a97449605a Refactor: Remove unused methods 2026-02-07 03:51:13 -06:00
hazelnut27
e53a7db9ed Enhance media encryption handling with hybrid resolver and improved decryption methods 2026-02-07 03:08:40 -06:00
particle-box
c16f1f130d feat: add website 2026-02-07 09:34:22 +05:30
particle-box
6eec014493 fix: translation issue 2026-02-07 01:26:52 +05:30
particle-box
a21b938be0 fix custom emoji & call recorder quality,add order feature for chat exports 2026-02-07 00:44:00 +05:30
particle-box
3744c5b422 fix: add support for more keys for translations 2026-02-06 23:29:57 +05:30
ΞTΞRNAL
4692987baf fix: Gemini Auto Reply By RSR 2026-02-06 21:39:35 +05:30
particle-box
869bf3f3b4 fix: improve display look 2026-02-06 10:43:57 +05:30
particle-box
ce67a4f2a7 fix: several bugs and inconsistencies 2026-02-06 10:41:31 +05:30
particle-box
6c1072171d Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-02-06 09:21:07 +05:30
particle-box
6559d4dcd2 fix: add more translations 2026-02-06 09:20:54 +05:30
ΞTΞRNAL
a1aef48b0c feat(PR): Google Maps Integration by Daplugg23 2026-02-06 09:17:28 +05:30
daplugg23
0779a496eb build: fix CLI build compatibility with Gradle 9.x/AGP 9.0
Add android.disallowKotlinSourceSets=false and android.sourceset.disallowProvider=false
to gradle.properties to resolve build failures caused by stricter checks in newer
Android Gradle Plugin versions. This allows the project to build successfully from CLI.
2026-02-05 20:25:04 -06:00
daplugg23
6227a078e4 fix(config): reorder location properties
Move locationSearchProvider and googleMapsApiKey properties in Global.kt
and en_US.json to be positioned between 'spoofLocation' and 'coordinates'.
This ensures they appear in the correct order in any auto-generated feature lists,
while maintaining their manual placement in BetterLocationRoot.kt.
2026-02-05 20:07:39 -06:00
daplugg23
dac42ea042 fix(location): force UI update on setting change
Use mutable state for locationSearchProvider and googleMapsApiKey display
values in BetterLocationRoot. Update these state variables immediately
when dialogs are dismissed/saved to ensure the UI reflects changes without
reopening the screen.

- Introduce currentProvider/currentApiKey state variables.
- Refresh state in onDismissRequest/callbacks.
2026-02-05 18:04:29 -06:00
daplugg23
0df57f7724 fix(location): ensure config persistence
Explicitly call context.config.writeConfig() when dismissing the
Location Search Provider or Google Maps API Key dialogs.
This ensures that the selected values are persisted immediately,
resolving issues where settings changes were lost or not applied.

Also wraps API key input dialog in explicit writeConfig logic.
2026-02-05 17:52:49 -06:00
daplugg23
8d6dcdb227 fix(location): correct UI layout and translation keys
Resolve vertical text rendering issue by using Modifier.weight(1f) in
ConfigSelector. Add proper translation keys to the 'better_location'
block in en_US.json so they are accessible to BetterLocationRoot.
Update key references to match the new structure.

- Add keys: location_search_provider_title, google_maps_api_key_title,
  option_osm, option_google_maps.
- Fix UI layout to handle text wrapping properly.
2026-02-05 17:40:13 -06:00
daplugg23
46e9b7e7f5 feat(location): implement Google Maps geocoding search
Add support for Google Maps Geocoding API in location search dialog.
- Update ChooseLocationDialog signature to accept API key.
- Switch search provider logic based on configuration.
- Use Google Maps Geocoding API when provider is 'google_maps'.
- Maintain OpenStreetMap Nominatim support as fallback/default.
- Pass configured API key from BetterLocationRoot UI.
2026-02-05 17:20:24 -06:00
daplugg23
d485718245 fix(location): remove disable option and add manual UI
- Add ConfigFlag.NO_DISABLE_KEY to locationSearchProvider config to prevent
  'Disable' (null) option which caused fallback to OSM.
- Add manual UI controls to BetterLocationRoot.kt for Location Search Provider
  and Google Maps API Key, as automatic UI generation was not present on this page.
- Add ConfigSelector and ConfigInput helpers to BetterLocationRoot.kt.
- Ensure API key input is hidden/masked in the UI list.
2026-02-05 17:13:44 -06:00
daplugg23
a4de81aaf6 fix(location): correct Google Maps tile source implementation
Replace XYTileSource with OnlineTileSourceBase anonymous object to correctly
handle Google Maps query parameter format (&x=...&y=...&z=...) instead of
path-based format (/z/x/y.png).

- Use correct tile servers (mt0-mt3)
- Override getTileURLString to construct valid Google Maps URLs
2026-02-05 16:52:41 -06:00
daplugg23
a8e0c53be0 feat(location): implement location search provider toggle
Add config properties for locationSearchProvider (osm/google_maps) and
googleMapsApiKey to BetterLocationConfig. Modify ChooseLocationDialog
to switch between OpenStreetMap (MAPNIK) and Google Maps tile sources
based on user preference.

- Add locationSearchProvider unique config property
- Add googleMapsApiKey string config property (SENSITIVE flag)
- Add XYTileSource for Google Maps tiles in AlertDialogs
- Pass locationSearchProvider from BetterLocationRoot to dialog

Translation keys already existed in all 28 language files.
2026-02-05 15:30:14 -06:00
daplugg23
a0728fa951 fix(i18n): remove duplicate ui_settings and update_settings keys 2026-02-05 14:59:50 -06:00
daplugg23
5c7b342195 feat(export): add saved locations export with toggle 2026-02-05 14:54:32 -06:00
daplugg23
67dcc16923 fix(i18n): merge duplicate JSON blocks in all 27 language files 2026-02-05 14:15:10 -06:00
daplugg23
214ec4ffdd fix(i18n): merge duplicate options/global blocks in en_US.json 2026-02-05 14:08:50 -06:00
particle-box
a821cdf85e feat: add confirmation dialogs 2026-02-05 23:14:30 +05:30
ΞTΞRNAL
1953397adf fix(PR): story decryption by hazelnut27
Fix story decryption
2026-02-05 12:46:06 +05:30
hazelnut27
c20c157c2b Fix story download and decryption 2026-02-04 23:53:48 -06:00
ΞTΞRNAL
60c1bf8569 fix(PR): multiple fixes by Daplugg23
Fix: OpenStreetMap User-Agent and Story Rewatch Indicator
2026-02-05 09:36:34 +05:30
daplugg23
08610ee398 Refactor: Move OpenStreetMap User-Agent to Constants 2026-02-04 20:35:06 -06:00
daplugg23
1e6e9ada62 Fix OpenStreetMap User-Agent and Story Rewatch Indicator 2026-02-04 20:22:58 -06:00
particle-box
528adc6255 Multiple fixes by RSR 2026-02-04 20:41:32 +05:30
ΞTΞRNAL
c4246e853c feat: Bulk Accept Friend Requests By RSR
Friend request
2026-02-04 20:35:10 +05:30
RSR/
ff398ccc9a Friend request 2026-02-04 11:52:32 +04:00
ΞTΞRNAL
3ce4617deb feat: add banner in README 2026-02-03 08:40:17 +05:30
ΞTΞRNAL
228d5e0b23 feat: add banner 2026-02-03 08:35:59 +05:30
319 changed files with 57219 additions and 31130 deletions

View File

@@ -45,7 +45,7 @@ body:
attributes:
label: "SnapEnhance Version"
description: "On which SnapEnhance version is this happening?"
placeholder: "ex. 1.2.5"
placeholder: "ex. 1.3.6"
validations:
required: true
- type: checkboxes

View File

@@ -105,101 +105,3 @@ jobs:
with:
name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
job_armv7:
runs-on: ubuntu-latest
outputs:
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/debug"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
"${ANDROID_HOME}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Debug-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Debug-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk

View File

@@ -26,7 +26,7 @@ jobs:
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleArmv8Debug
@@ -46,49 +46,6 @@ jobs:
with:
name: purrfectsnap-armv8-debug
path: app/build/outputs/apk/armv8/debug/*.apk
job_armv7:
runs-on: macos-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Debug
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Rename APK file
run: |
mv app/build/outputs/apk/armv7/debug/*.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
job_manager:
runs-on: ubuntu-latest
@@ -136,7 +93,7 @@ jobs:
run: chmod +x gradlew
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Build
run: ./gradlew --configuration-cache assembleCoreDebug

View File

@@ -6,6 +6,8 @@ on:
description: 'Upload to CI channel'
required: false
type: boolean
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
job_armv8:
runs-on: ubuntu-latest
@@ -13,11 +15,11 @@ jobs:
armv8_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v4.7.0
with:
java-version: '21'
distribution: 'temurin'
@@ -27,7 +29,7 @@ jobs:
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
run: rustup target add aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
@@ -101,118 +103,20 @@ jobs:
mv app/build/outputs/apk/armv8/release/purrfectsnap-armv8Release-signed.apk app/build/outputs/apk/armv8/release/purrfectsnap_${{ env.version }}-armv8Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
with:
name: purrfectsnap-armv8-release
path: app/build/outputs/apk/armv8/release/*.apk
job_armv7:
runs-on: ubuntu-latest
outputs:
armv7_apk: ${{ steps.upload_apk.outputs.artifact-paths }}
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Setup NPM Dependencies
run: npm install typescript -g
- name: Add Android targets for Rust
run: rustup target add armv7-linux-androideabi aarch64-linux-android
- name: Prepare release keystore for Gradle signing
env:
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
mkdir -p "${HOME}/.android"
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${HOME}/.android/purrfectsnap-release.keystore"
if [ ! -s "${HOME}/.android/purrfectsnap-release.keystore" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
mkdir -p "${HOME}/.gradle"
{
echo "PS_RELEASE_STORE_PASSWORD=${SIGNING_STORE_PASSWORD}"
echo "PS_RELEASE_KEY_ALIAS=${SIGNING_KEY_ALIAS}"
echo "PS_RELEASE_KEY_PASSWORD=${SIGNING_KEY_PASSWORD}"
} >> "${HOME}/.gradle/gradle.properties"
- name: Build
run: ./gradlew --configuration-cache assembleArmv7Release
- name: Determine the latest Build Tools version installed
shell: bash
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
echo "SDK_ROOT=${SDK_ROOT}" >> $GITHUB_ENV
echo "BUILD_TOOL_VERSION=$(ls "${SDK_ROOT}/build-tools/" | tail -n 1)" >> $GITHUB_ENV
- name: Sign APK
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
SIGNING_KEY_BASE64: ${{ secrets.PS_BASE_64 }}
SIGNING_KEY_ALIAS: ${{ secrets.PS_RELEASE_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.PS_RELEASE_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.PS_RELEASE_STORE_PASSWORD }}
run: |
set -euo pipefail
SIGNING_DIR="app/build/outputs/apk/armv7/release"
KEYSTORE_PATH="${SIGNING_DIR}/signingKey.jks"
if [ -z "${SIGNING_KEY_BASE64}" ] || [ -z "${SIGNING_KEY_ALIAS}" ] || [ -z "${SIGNING_STORE_PASSWORD}" ] || [ -z "${SIGNING_KEY_PASSWORD}" ]; then
echo "Signing secrets are missing. Ensure PS_BASE_64, PS_RELEASE_KEY_ALIAS, PS_RELEASE_STORE_PASSWORD, PS_RELEASE_KEY_PASSWORD are set." >&2
exit 1
fi
printf '%s' "${SIGNING_KEY_BASE64}" | base64 --decode > "${KEYSTORE_PATH}"
if [ ! -s "${KEYSTORE_PATH}" ]; then
echo "Decoded keystore is empty or invalid." >&2
exit 1
fi
APK_INPUT=$(ls "${SIGNING_DIR}"/*.apk | head -n 1)
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/zipalign" -v 4 "${APK_INPUT}" "${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
"${SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
--ks "${KEYSTORE_PATH}" \
--ks-key-alias "${SIGNING_KEY_ALIAS}" \
--ks-pass "pass:${SIGNING_STORE_PASSWORD}" \
--key-pass "pass:${SIGNING_KEY_PASSWORD}" \
--ks-type JKS \
--out "${SIGNING_DIR}/purrfectsnap-armv7Release-signed.apk" \
"${SIGNING_DIR}/purrfectsnap-armv7Release-aligned.apk"
- name: Get current build version
id: version-env
run: |
./gradlew --configuration-cache getVersion
echo "version=$(cat app/build/version.txt)" >> $GITHUB_ENV
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/release/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/release/purrfectsnap-armv7Release-signed.apk app/build/outputs/apk/armv7/release/purrfectsnap_${{ env.version }}-armv7Release.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-release
path: app/build/outputs/apk/armv7/release/*.apk
make_release:
needs: [job_armv7, job_armv8]
needs: [job_armv8]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
- name: Download all build artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v4.2.1
with:
path: ./all-apks
- name: Display all files for debug

5
.gitignore vendored
View File

@@ -4,9 +4,11 @@ local.properties
/.idea/
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
.local/
native/.omvll/
cloudflare/**/.wrangler/
cloudflare/**/node_modules/
@@ -18,3 +20,6 @@ cloudflare/**/allowed_codes.local.*
security/allowed-codes.local.*
security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

265
README.md
View File

@@ -1,6 +1,10 @@
<div align="center">
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/logo.png" alt="PurrfectSnap" width="180"/>
<div align="center">
<img src="https://github.com/particle-box/PurrfectSnap/blob/dev/banner.jpg" alt="PurrfectSnap" width="100%"/>
</div>
# PurrfectSnap
@@ -12,7 +16,7 @@
[![Downloads](https://img.shields.io/github/downloads/particle-box/PurrfectSnap/total?style=for-the-badge&color=f5bde6&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/releases)
[![License](https://img.shields.io/badge/License-GPL_3.0-f5a97f?style=for-the-badge&labelColor=1e1e2e)](https://github.com/particle-box/PurrfectSnap/blob/dev/LICENSE)
[Installation](#installation) • [Features](#features) • [Changelog](#whats-new) • [Community](#community)
[Installation](#installation) • [Features](#features) • [Build](#build-from-source) • [Community](#community)
</div>
@@ -36,44 +40,9 @@ No aggressive donation requests. No minimal changes disguised as “major update
<br>
---
<br>
## 🔄 How It Works
```mermaid
graph TD
A[Install PurrfectSnap] --> B{Device Type?}
B -->|Non-Rooted| C[Auto Patcher Runs]
B -->|Rooted| D[Xposed Framework]
C --> E[Snapchat Patched Automatically]
D --> E
E --> F[Launch Snapchat]
F --> G[Access PurrfectSnap Settings]
G --> H[Configure Features]
H --> I{Enable Features}
I --> J[Media Downloader]
I --> K[Privacy Controls]
I --> L[UI Customization]
I --> M[Camera Enhancements]
I --> N[AI Intelligence Layer]
J --> O[Enhanced Snapchat Experience]
K --> O
L --> O
M --> O
N --> O
```
<br>
---
<br>
## Features
PurrfectSnap offers deep control across media, privacy, automation, and UIdesigned for both casual users and power users.
PurrfectSnap offers deep control across media, privacy, automation, and UI:designed for both casual users and power users.
<table>
<tr>
@@ -242,152 +211,6 @@ Extensibility through custom scripts.
<br>
---
<br>
## What's New
Here's what makes PurrfectSnap different from upstream SnapEnhance.
<br>
### Aurora Design System
A complete visual language rebuilt from the ground up. Not just a theme, an entire design philosophy.
The interface now flows with purpose. Subtle animations guide your interactions. Every screen has been reconsidered, every transition refined. The result is an experience that feels premium without being ostentatious.
<br>
### PurrAura
The ban problem? Solved.
No more sacrificing features to stay safe. Add friends, block users, attach music; everything works as it should. No workarounds, no compromises, no disadvantages.
<br>
### Auto Patcher
Installation was complicated. Now it isnt.
We've eliminated 90% of the installation friction. No Shizuku. No LSPatch. Just a straightforward process that respects your time.
<br>
### Intelligence Layer
**Auto Reply**
Let AI handle routine responses while you focus on what matters. Configure automatic replies for chats, stories, and half-swipes. Essential for creators managing volume. Requires an API key from Google AI Studio, they offer free tier access.
**Scheduled Snaps**
Time-shifted communication. Set a snap to send at midnight for a birthday. Queue content for optimal timing:your schedule, automated.
**Message Translator**
Language barriers, eliminated. Incoming messages automatically translate to your preferred language. Set it once, communicate globally.
<br>
### Privacy Enhancements
**Granular Controls**
Whitelist and blacklist modes for typing indicators and message logging. Choose exactly who sees what, friend by friend.
**Auto Delete Messages**
Set expiration times for your messages. They disappear on your schedule, not Snapchat's.
**Message Logger Viewer**
Review exported logs directly in-app. No external tools required.
<br>
### Data Persistence
**Friend Notes Backup**
Your notes are valuable. Now they're protected. Export and restore with ease. Never lose context again.
<br>
### Device Management
**ID Spoofing**
Device bans happen. Sometimes unjustly. We offer a solution for legitimate cases, approval required with proof of wrongful ban. This feature is restricted to prevent abuse.
**Model Spoofing**
Identify as a high-end device model. Potential performance improvements, enhanced camera processing, and better feature access.
**Network Spoofing**
Spoof your network status to Wi-Fi always to prevent several restrictions on mobile data.
<br>
### Restored Functionality
Features that disappeared? They're back.
Bulk messaging works again. Friend list management is fully operational. These aren't new features, they're restored capabilities that should never have been lost.
<br>
### Discovery
**Scripts Catalog**
Browse and install scripts without leaving the app. No more hunting for import links. One-tap installation. Always current.
**Friend Tracker Catalog**
Browse and install tracking rules without leaving the app. One-tap installation. Always current.
**Friend Tracker Import/Export**
Backup and restore your Friend Tracker configurations with ease.
<br>
### Advanced Tracking
**I Can See You Rule**
Track the exact time and duration when a friend enters your chat. Precision visibility tracking for Friend Tracker.
**Auto Open Snaps Enhancement**
More granular control with queue size configuration and customizable delays.
<br>
### Visual Refinements
**AMOLED Theme**
True black. Battery-saving. The most requested feature, delivered.
**Haptic Feedback**
Subtle tactile responses. Every interaction confirmed through touch.
**Customizable Bottom Bar**
Your navigation, your rules. Rearrange tabs, remove what you don't use, set your default view. Complete flexibility.
**Search History**
Recently searched features, instantly accessible. No more hunting through menus.
<br>
### Infrastructure
**In-App Updates**
New versions install directly from within the app. No GitHub hunting. No manual downloads. Just seamless updates.
**Update Notifications**
Stay current automatically. Get notified when new releases are available.
**Config Export/Import Summary**
See exactly which features a config will enable before importing or exporting. Know what you're getting.
<br>
### Quality
Hundreds of refinements you'll feel but might not see. Smoother animations. Faster response times. Eliminated edge cases. The kind of polish that separates good from exceptional.
> Some features are intended for educational, recovery, or accessibility purposes. Misuse may violate Snapchats terms of service.
> **We are not affiliated with Snap Inc. and are not responsible for any violations by the user. Our sole purpose is for education purposes only!**
@@ -421,15 +244,79 @@ Questions? Ideas? Found a bug? Our community is active and responsive.
<br>
## Contributing
## Build from Source
We welcome contributions from developers who share our vision. Whether it's code, documentation, etc., contributions are always appreciated. Feel free to open a PR :)
<br>
### 1) Fork
---
1. Fork this repository on GitHub.
```
### 2) Generate a Release Keystore (Certificate)
Create a signing keystore (`.jks`) and keep it secure:
```bash
keytool -genkeypair -v \
-keystore purrfectsnap-release.keystore \
-storetype JKS \
-alias purrfectsnap \
-keyalg RSA \
-keysize 2048 \
-validity 10000
```
Optional local placement for Gradle signing:
```bash
mkdir -p ~/.android
cp purrfectsnap-release.keystore ~/.android/purrfectsnap-release.keystore
```
### 3) Convert Keystore to Base64
For GitHub Actions secret `PS_BASE_64`:
Linux/macOS:
```bash
base64 -w 0 purrfectsnap-release.keystore > keystore.base64
```
PowerShell (Windows):
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("purrfectsnap-release.keystore")) | Set-Content -NoNewline keystore.base64
```
### 4) Add Required GitHub Secrets (Your Fork)
Go to:
`Settings -> Secrets and variables -> Actions -> New repository secret`
Create these exact secrets (from `.github/workflows/release.yml`):
- `PS_BASE_64` = contents of `keystore.base64`
- `PS_RELEASE_KEY_ALIAS` = `purrfectsnap` (or your chosen alias)
- `PS_RELEASE_KEY_PASSWORD` = key password from `keytool`
- `PS_RELEASE_STORE_PASSWORD` = keystore password from `keytool`
### 5) Run Release Workflow
1. Open `Actions` in your fork.
2. Run `PurrfectSnap Release CI` (`workflow_dispatch`).
3. Workflow builds and signs:
- `armv8` release APK
- `armv7` release APK
4. Workflow creates a GitHub Release and uploads APK assets.
### Notes
- CI uses `JDK 21 (Temurin)`.
- Signing requires all four secrets above.
- Keep keystore and passwords private. Rotate immediately if exposed.
<br>
## Credits
@@ -437,7 +324,7 @@ PurrfectSnap is built with exceptional open source tools. We do not collect any
**Core Dependencies**
- [SnapEnhance](https://github.com/rhunk/SnapEnhance) — The foundation
- [libxposed](https://github.com/libxposed/api) — Framework integration
- [YukiHook](https://github.com/HighCapable/YukiHookAPI) — Framework integration
- [Jingmatrix Lspatch](https://github.com/JingMatrix/LSPatch) — Auto Patcher
- [Dobby](https://github.com/jmpews/Dobby) — Native hooking
- [OMVLL](https://github.com/open-obfuscator/o-mvll) — Obfuscation

View File

@@ -1 +1 @@
- Test
- Temporary fix for conversations disappearing: Turn off Block Ads feature

View File

@@ -113,7 +113,6 @@ android {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
@@ -151,7 +150,7 @@ android {
applicationId = rootProject.ext["applicationId"].toString()
versionCode = rootProject.ext["appVersionCode"].toString().toInt()
versionName = rootProject.ext["appVersionName"].toString()
minSdk = 28
minSdk = 30
targetSdk = 36
multiDexEnabled = true
buildConfigField("String", "EXPECTED_CERT_SHA256", "\"${expectedCertSha256.get()}\"")
@@ -166,6 +165,9 @@ android {
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
} else {
// Keep local release builds installable when private release credentials are unavailable.
signingConfig = signingConfigs.getByName("debug")
}
}
debug {
@@ -267,6 +269,7 @@ dependencies {
}
implementation(project(":core"))
compileOnly(files("../core/libs/LSPosed-api-1.0-SNAPSHOT.jar"))
implementation(project(":common"))
implementation(project(":native"))
implementation(libs.androidx.documentfile)
@@ -355,6 +358,7 @@ afterEvaluate {
}
}
}
}
properties["debug_flavor"]?.let {

View File

@@ -1,6 +1,6 @@
-dontwarn de.robv.android.xposed.**
-dontwarn org.mozilla.javascript.**
-dontwarn android.app.AndroidAppHelper
-dontwarn java.lang.reflect.AnnotatedType
-keep class com.tonyodev.fetch2.** { *; }
-keep class com.tonyodev.fetch2core.** { *; }

View File

@@ -1 +1 @@
0.7
0.8

View File

@@ -7,6 +7,8 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.core.app.CoreComponentFactory
@@ -30,12 +32,14 @@ import androidx.work.WorkManager
import me.eternal.purrfectsnap.bridge.BridgeService
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
import me.eternal.purrfectsnap.common.config.ModConfig
import me.eternal.purrfectsnap.common.logger.fatalCrash
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.common.util.constantLazyBridge
import me.eternal.purrfectsnap.common.util.getPurgeTime
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
@@ -275,6 +279,67 @@ class RemoteSideContext(
androidContext.startActivity(intent)
}
fun requestSocialSnapshotRefresh(
openSnapchatFirst: Boolean = true,
snapchatWarmupDelayMs: Long = 1200L,
returnDelayMs: Long = 1200L
) {
fun sendSocialSnapshotBroadcast() {
runCatching {
androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
log.error("Failed to request latest social snapshot", it)
}
}
if (!openSnapchatFirst) {
sendSocialSnapshotBroadcast()
return
}
val snapchatIntent = androidContext.packageManager
.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (snapchatIntent == null) {
shortToast(translation["toast_snapchat_not_installed"])
sendSocialSnapshotBroadcast()
return
}
val returnIntent = Intent(androidContext, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
}
val mainHandler = Handler(Looper.getMainLooper())
runCatching {
androidContext.startActivity(snapchatIntent)
mainHandler.postDelayed(
{
runCatching {
androidContext.startActivity(returnIntent)
}.onFailure {
log.error("Failed to return to PurrfectSnap after Snapchat handoff", it)
}
mainHandler.postDelayed(
{ sendSocialSnapshotBroadcast() },
returnDelayMs
)
},
snapchatWarmupDelayMs
)
}.onFailure {
log.error("Failed to launch Snapchat for social snapshot refresh", it)
sendSocialSnapshotBroadcast()
}
}
private fun scheduleAnnouncementCheck() {
val workManager = WorkManager.getInstance(androidContext)
val constraints = Constraints.Builder()

View File

@@ -4,6 +4,10 @@ import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -27,10 +31,25 @@ import kotlin.system.measureTimeMillis
class BridgeService : Service() {
private lateinit var remoteSideContext: RemoteSideContext
lateinit var syncCallback: SyncCallback
private var syncCallback: SyncCallback? = null
var messagingBridge: MessagingBridge? = null
@Volatile
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
private fun clearSyncCallback() {
syncCallback = null
}
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
pendingSocialSnapshotCallback = callback
}
fun clearEphemeralSocialSnapshotRequest() {
pendingSocialSnapshotCallback = null
}
override fun onDestroy() {
clearSyncCallback()
if (::remoteSideContext.isInitialized) {
remoteSideContext.bridgeService = null
}
@@ -47,36 +66,58 @@ class BridgeService : Service() {
}
fun triggerScopeSync(scope: SocialScope, id: String, updateOnly: Boolean = false) {
val callback = syncCallback ?: return
runCatching {
if (!syncCallback.asBinder().pingBinder()) {
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return
}
val database = remoteSideContext.database
val syncedObject = when (scope) {
SocialScope.FRIEND -> {
if (updateOnly && database.getFriendInfo(id) == null) return
syncCallback.syncFriend(id)
callback.syncFriend(id)
}
SocialScope.GROUP -> {
if (updateOnly && database.getGroupInfo(id) == null) return
syncCallback.syncGroup(id)
callback.syncGroup(id)
}
} ?: run {
if (updateOnly) {
when (scope) {
SocialScope.FRIEND -> database.deleteFriend(id)
SocialScope.GROUP -> database.deleteGroup(id)
}
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
when (scope) {
SocialScope.FRIEND -> {
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) }
toParcelable<MessagingFriendInfo>(syncedObject)?.let { database.syncFriend(it) } ?: run {
if (updateOnly) {
database.deleteFriend(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
SocialScope.GROUP -> {
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) }
toParcelable<MessagingGroupInfo>(syncedObject)?.let { database.syncGroupInfo(it) } ?: run {
if (updateOnly) {
database.deleteGroup(id)
return
}
remoteSideContext.log.warn("Failed to sync $scope $id")
return
}
}
}
}.onFailure {
if (it is RemoteException) {
clearSyncCallback()
remoteSideContext.log.warn("Failed to sync $scope $id: Callback is dead")
return@onFailure
}
remoteSideContext.log.error("Failed to sync $scope $id", it)
}
}
@@ -121,7 +162,7 @@ class BridgeService : Service() {
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Media conversion",
title = remoteSideContext.translation["task_media_conversion_title"],
author = null,
hash = taskId
)
@@ -162,6 +203,7 @@ class BridgeService : Service() {
}
override fun sync(callback: SyncCallback) {
clearSyncCallback()
syncCallback = callback
measureTimeMillis {
remoteSideContext.database.getFriends().map { it.userId } .forEach { friendId ->
@@ -180,15 +222,42 @@ class BridgeService : Service() {
triggerScopeSync(SocialScope.getByName(scope), id, true)
}
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
override fun passGroupsAndFriends(
groups: List<String>,
friends: List<String>
friends: List<String>,
chunkIndex: Int,
totalChunks: Int
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
remoteSideContext.database.receiveMessagingDataCallback(
friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) },
groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
)
synchronized(friendAccumulator) {
if (chunkIndex == 0) {
friendAccumulator.clear()
groupAccumulator.clear()
}
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
if (chunkIndex == totalChunks - 1) {
val finalFriends = friendAccumulator.toList()
val finalGroups = groupAccumulator.toList()
friendAccumulator.clear()
groupAccumulator.clear()
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(finalFriends, finalGroups)
}
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
}
}
}
}
override fun getScopeNotes(id: String): String? {

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.download
import android.content.ContentUris
import android.content.ContentValues
import android.content.Intent
import android.graphics.Bitmap
@@ -11,11 +12,9 @@ import android.provider.MediaStore
import android.widget.Toast
import androidx.documentfile.provider.DocumentFile
import com.google.gson.GsonBuilder
import kotlinx.coroutines.Job
import kotlinx.coroutines.job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.bridge.DownloadCallback
import me.eternal.purrfectsnap.common.Constants
@@ -61,6 +60,14 @@ class DownloadProcessor (
private val remoteSideContext: RemoteSideContext,
private val callback: DownloadCallback
) {
companion object {
private val downloadSemaphore = Semaphore(3)
}
private data class GallerySaveResult(
val uri: Uri,
val alreadyDownloaded: Boolean = false
)
private val translation by lazy {
remoteSideContext.translation.getCategory("download_processor")
@@ -114,82 +121,41 @@ class DownloadProcessor (
inputFile.outputStream().use {
bitmap.compress(compressFormat, 100, it)
}
bitmap.recycle()
fileType = FileType.fromFile(inputFile)
}
}
val fileName = metadata.outputPath.substringAfterLast("/") + "." + fileType.fileExtension
val fileName = buildOutputFileName(metadata.outputPath, fileType)
val configuredFolder = remoteSideContext.config.root.downloader.saveFolder.get().orEmpty().trim()
if (configuredFolder.isBlank()) {
val outputUri = saveToSystemDefault(fileName, fileType, inputFile, metadata)
?: throw Exception("Failed to save media (no output uri)")
pendingTask.task.extra = outputUri.toString()
pendingTask.success()
callbackOnSuccess(fileName)
return
}
val saveResult = if (configuredFolder.isBlank()) {
saveToSystemDefault(fileName, fileType, inputFile, metadata)
} else {
runCatching {
saveToConfiguredFolder(
configuredFolder = configuredFolder,
fileName = fileName,
fileType = fileType,
inputFile = inputFile,
metadata = metadata,
pendingTask = pendingTask
)
}.onFailure {
remoteSideContext.log.error("Failed to save to configured folder, falling back to system default", it)
}.getOrNull() ?: saveToSystemDefault(fileName, fileType, inputFile, metadata)
} ?: throw Exception("Failed to save media (no output uri)")
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
pendingTask.task.extra = saveResult.uri.toString()
pendingTask.success()
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name) ?: folder.createDirectory(name)!!
}
} else {
outputFolder
}
}
// checks if the file already exists and if it does, compares its contents with the input file, if contents differ, deletes existing file.
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
return@let
}
remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)?.use { existingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputFile.inputStream().use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = existingInputStream.read(buffer2)
if (read1 != read2 || !buffer1.contentEquals(buffer2)) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
}
}
pendingTask.task.extra = existingFile.uri.toString()
pendingTask.success()
if (saveResult.alreadyDownloaded) {
callbackOnFailure(translation["already_downloaded_toast"])
return
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)!!
pendingTask.updateProgress("Saving media to gallery")
remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)!!.use { outputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
pendingTask.task.extra = outputFile.uri.toString()
pendingTask.success()
runCatching {
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = outputFile.uri
data = saveResult.uri
})
}.onFailure {
remoteSideContext.log.error("Failed to scan media file", it)
@@ -205,15 +171,155 @@ class DownloadProcessor (
}
}
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
stream1.use { s1 ->
stream2.use { s2 ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
while (true) {
val read1 = s1.read(buffer1)
val read2 = s2.read(buffer2)
if (read1 != read2) return false
if (read1 == -1) return true
for (i in 0 until read1) {
if (buffer1[i] != buffer2[i]) return false
}
}
}
}
}
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaStore.MediaColumns.DISPLAY_NAME} = ? AND ${MediaStore.MediaColumns.RELATIVE_PATH} = ?"
val selectionArgs = arrayOf(fileName, relativePath)
return remoteSideContext.androidContext.contentResolver.query(
collection,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
ContentUris.withAppendedId(collection, id)
}
}
private fun contentMatches(uri: Uri, inputFile: File): Boolean {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(uri) ?: return false
return streamsMatch(existingInputStream, inputFile.inputStream())
}
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
rawName.substringBeforeLast(".")
} else {
rawName
}
return "${sanitizeFileName(baseName)}.$targetExtension"
}
private fun sanitizeFileName(name: String): String {
return name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ")
.trim()
.trim('.')
.ifBlank { "media" }
}
private fun sanitizeRelativePath(path: String): String {
return path.trimEnd('/').split("/")
.mapNotNull { segment ->
segment.trim()
.takeIf { it.isNotBlank() }
?.let(::sanitizeFileName)
}
.joinToString("/")
}
private fun appendNameSuffix(fileName: String, index: Int): String {
val extension = fileName.substringAfterLast('.', "")
val baseName = fileName.substringBeforeLast(".", fileName)
return if (extension.isBlank()) {
"$baseName ($index)"
} else {
"$baseName ($index).$extension"
}
}
private fun saveToConfiguredFolder(
configuredFolder: String,
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
pendingTask: PendingTask,
): GallerySaveResult {
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name)
?: folder.createDirectory(name)
?: throw Exception("Failed to create output directory $name")
}
} else {
outputFolder
}
}
var finalFileName = fileName
var collisionCount = 0
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
collisionCount++
finalFileName = appendNameSuffix(fileName, collisionCount)
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
?: throw Exception("Failed to create output file $finalFileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $finalFileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun saveToSystemDefault(
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
): Uri? {
val subPath = metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
.trim('/')
): GallerySaveResult? {
val subPath = sanitizeRelativePath(
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
)
val baseRelative = when {
fileType.isImage -> Environment.DIRECTORY_PICTURES
fileType.isVideo -> Environment.DIRECTORY_MOVIES
@@ -222,40 +328,65 @@ class DownloadProcessor (
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
.joinToString("/") + "/"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val collection = when {
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
}
val resolver = remoteSideContext.androidContext.contentResolver
val uri = resolver.insert(collection, values) ?: return null
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: return null
uri
for (attempt in 0..100) {
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
return@let
} ?: run {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) }
return GallerySaveResult(uri)
}.onFailure {
runCatching { resolver.delete(uri, null, null) }
}
}
}
throw IllegalStateException("Failed to allocate unique filename")
} else {
@Suppress("DEPRECATION")
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
destDir.mkdirs()
val destFile = File(destDir, fileName)
FileOutputStream(destFile).use { out ->
inputFile.inputStream().use { it.copyTo(out) }
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
}
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
runCatching {
remoteSideContext.androidContext.sendBroadcast(
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = Uri.fromFile(destFile)
}
)
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
}
Uri.fromFile(destFile)
return GallerySaveResult(Uri.fromFile(destFile))
}
}
@@ -263,8 +394,7 @@ class DownloadProcessor (
return File.createTempFile("media", ".tmp")
}
private fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest) = runBlocking {
val jobs = mutableListOf<Job>()
private suspend fun downloadInputMedias(pendingTask: PendingTask, downloadRequest: DownloadRequest): Map<InputMedia, File> {
val downloadedMedias = mutableMapOf<InputMedia, File>()
var totalSize = 1L
val inputMediaDownloadedBytes = mutableMapOf<InputMedia, Long>()
@@ -277,71 +407,72 @@ class DownloadProcessor (
)
}
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
coroutineScope {
downloadRequest.inputMedias.forEach { inputMedia ->
fun setProgress(progress: String) {
inputMediaProgress[inputMedia] = progress
updateDownloadProgress()
}
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
fun handleInputStream(inputStream: InputStream, estimatedSize: Long = 0L) {
createMediaTempFile().apply {
val decryptedInputStream = (inputMedia.encryption?.decryptInputStream(inputStream) ?: inputStream).buffered()
val buffer = ByteArray(1024 * 1024 * 2) // 2MB
var read: Int
var totalRead = 0L
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
outputStream().use { outputStream ->
while (decryptedInputStream.read(buffer).also { read = it } != -1) {
outputStream.write(buffer, 0, read)
totalRead += read
inputMediaDownloadedBytes[inputMedia] = totalRead
setProgress("${totalRead / 1024}KB/${estimatedSize / 1024}KB")
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}.also { downloadedMedias[inputMedia] = it }
}
launch {
when (inputMedia.type) {
DownloadMediaType.PROTO_MEDIA -> {
RemoteMediaResolver.downloadBoltMedia(Base64.UrlSafe.decode(inputMedia.content), decryptionCallback = { it }, resultCallback = { inputStream, length ->
totalSize += length
inputStream.use {
handleInputStream(it, estimatedSize = length)
}
})
}
DownloadMediaType.REMOTE_MEDIA -> {
with(URL(inputMedia.content).openConnection() as HttpURLConnection) {
requestMethod = "GET"
setRequestProperty("User-Agent", Constants.USER_AGENT)
connect()
totalSize += contentLength.toLong()
inputStream.use {
handleInputStream(it, estimatedSize = contentLength.toLong())
}
}
}
DownloadMediaType.DIRECT_MEDIA -> {
val decoded = Base64.UrlSafe.decode(inputMedia.content)
totalSize += decoded.size.toLong()
handleInputStream(decoded.inputStream(), estimatedSize = decoded.size.toLong())
}
else -> {
File(inputMedia.content).inputStream().use {
totalSize += it.available().toLong()
handleInputStream(it, estimatedSize = it.available().toLong())
}
}
}
}
}.also { jobs.add(it) }
}
}
jobs.joinAll()
downloadedMedias
return downloadedMedias
}
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, DownloadedFile>, downloadRequest: DownloadRequest) {
private suspend fun downloadRemoteMedia(pendingTask: PendingTask, metadata: DownloadMetadata, downloadedMedias: Map<InputMedia, File>, downloadRequest: DownloadRequest) {
downloadRequest.inputMedias.first().let { inputMedia ->
val mediaType = inputMedia.type
val media = downloadedMedias[inputMedia]!!
@@ -352,34 +483,37 @@ class DownloadProcessor (
val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.CONVERSION,
inputs = listOf(media.file.absolutePath),
inputs = listOf(media.absolutePath),
output = outputFile
))
media.file.delete()
media.delete()
saveMediaToGallery(pendingTask, outputFile, metadata)
outputFile.delete()
return
}
}
saveMediaToGallery(pendingTask, media.file, metadata)
media.file.delete()
saveMediaToGallery(pendingTask, media, metadata)
media.delete()
return
}
assert(mediaType == DownloadMediaType.REMOTE_MEDIA)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media.file)
val playlistXml = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(media)
val baseUrlNodeList = playlistXml.getElementsByTagName("BaseURL")
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
val baseUrl = baseUrlNode.textContent
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
// FIX: Only add prefix if it's not already a full URL
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
}
}
val dashOptions = downloadRequest.dashOptions!!
val dashPlaylistFile = renameFromFileType(media.file, FileType.MPD)
val dashPlaylistFile = renameFromFileType(media, FileType.MPD)
dashPlaylistFile.outputStream().use {
TransformerFactory.newInstance().newTransformer().transform(DOMSource(playlistXml), StreamResult(it))
}
@@ -404,7 +538,7 @@ class DownloadProcessor (
dashPlaylistFile.delete()
outputFile.delete()
media.file.delete()
media.delete()
}
}
@@ -424,7 +558,7 @@ class DownloadProcessor (
// check if the media file has been deleted
if (task.type == TaskType.DOWNLOAD) {
val outputFile = runCatching {
DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(task.extra))
DocumentFile.fromSingleUri(remoteSideContext.androidContext, Uri.parse(task.extra))
}.getOrNull()
if (outputFile != null && !outputFile.exists()) {
@@ -439,113 +573,171 @@ class DownloadProcessor (
return@launch
}
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
downloadSemaphore.withPermit {
callbackOnProgress(translation["download_started_toast"])
remoteSideContext.log.debug("downloading media")
val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier
)
).apply {
status = TaskStatus.RUNNING
addListener(PendingTaskListener(onCancel = {
coroutineContext.job.cancel()
}))
updateProgress("Downloading...")
}
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to DownloadedFile(it.value, FileType.fromFile(it.value))
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
runCatching {
if (downloadRequest.isAudioStream) {
val streamUrl = downloadRequest.inputMedias.first().content
val outputFile = File.createTempFile("audio_stream", ".mp3")
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
callbackOnProgress("Downloading audio stream")
pendingTask.updateProgress("Downloading audio stream")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_AUDIO_STREAM,
inputs = listOf(streamUrl),
output = outputFile,
audioStreamFormat = downloadRequest.audioStreamFormat
))
saveMediaToGallery(pendingTask, outputFile, downloadMetadata)
return@launch
}
//if there is a zip file, extract it and replace the downloaded media with the extracted ones
downloadedMedias.values.find { it.fileType == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
downloadedMedias.clear()
//first download all input medias into cache
val downloadedMedias = downloadInputMedias(pendingTask, downloadRequest).map {
it.key to it.value
}.toMap().toMutableMap()
remoteSideContext.log.verbose("downloaded ${downloadedMedias.size} medias")
zipFile.file.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
createMediaTempFile().apply {
outputStream().use {
inputStream.copyTo(it)
var shouldMergeOverlay = downloadRequest.shouldMergeOverlay
//if there is a zip file, extract it and save only the base media (discard overlay)
downloadedMedias.values.find { FileType.fromFile(it) == FileType.ZIP }?.let { zipFile ->
val oldDownloadedMedias = downloadedMedias.toMap()
// Debug Stories: save raw ZIP to the configured debug folder
val debugConfig = remoteSideContext.config.root.developer.debugStories
if (debugConfig.enabled.get()) {
val debugFolder = debugConfig.saveFolder.get().orEmpty().trim()
if (debugFolder.isNotBlank()) {
runCatching {
val debugDir = android.net.Uri.parse(debugFolder).let { uri ->
androidx.documentfile.provider.DocumentFile.fromTreeUri(remoteSideContext.androidContext, uri)
}
val timestamp = System.currentTimeMillis()
val debugFile = debugDir?.createFile("application/zip", "story_debug_${timestamp}.zip")
debugFile?.uri?.let { fileUri ->
remoteSideContext.androidContext.contentResolver.openOutputStream(fileUri)?.use { out ->
zipFile.inputStream().use { it.copyTo(out) }
}
}
}.onFailure {
remoteSideContext.log.warn("Debug Stories: failed to save zip: ${it.message}", "DownloadProcessor")
}
}.also {
downloadedMedias[InputMedia(
type = DownloadMediaType.LOCAL_MEDIA,
content = it.absolutePath,
isOverlay = type == SplitMediaAssetType.OVERLAY
)] = DownloadedFile(it, FileType.fromFile(it))
} else {
remoteSideContext.log.warn("Debug Stories: enabled but no folder configured", "DownloadProcessor")
}
}
var baseMediaFile: File? = null
zipFile.inputStream().use { zipFileInputStream ->
MediaDownloaderHelper.getSplitElements(zipFileInputStream) { type, inputStream ->
if (type == SplitMediaAssetType.OVERLAY) return@getSplitElements
baseMediaFile = createMediaTempFile().apply {
outputStream().use { inputStream.copyTo(it) }
}
}
}
oldDownloadedMedias.forEach { (_, value) -> value.delete() }
baseMediaFile?.let { baseMedia ->
saveMediaToGallery(pendingTask, baseMedia, downloadMetadata)
baseMedia.delete()
}
return@launch
}
oldDownloadedMedias.forEach { (_, value) ->
value.file.delete()
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val mediaFileType = FileType.fromFile(media)
val overlayFileType = FileType.fromFile(overlayMedia)
val renamedMedia = renameFromFileType(media, mediaFileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType)
if (mediaFileType.isImage && overlayFileType.isImage) {
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image")
val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image")
val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap)
val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg"))
val compressFormat = when (mediaFileType) {
FileType.PNG -> Bitmap.CompressFormat.PNG
FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
else -> Bitmap.CompressFormat.JPEG
}
mergedImage.outputStream().use {
mergedBitmap.compress(compressFormat, 100, it)
}
originalBitmap.recycle()
overlayBitmap.recycle()
mergedBitmap.recycle()
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
mergedImage.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}.onFailure {
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
}
}
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
shouldMergeOverlay = true
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2)
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media.file, media.fileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia.file, overlayMedia.fileType)
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.file.nameWithoutExtension))
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY,
inputs = listOf(renamedMedia.absolutePath),
output = mergedOverlay,
overlay = renamedOverlayMedia
))
saveMediaToGallery(pendingTask, mergedOverlay, downloadMetadata)
}.onFailure { exception ->
if (coroutineContext.job.isCancelled) return@onFailure
remoteSideContext.log.error("Failed to merge overlay", exception)
callbackOnFailure(translation.format("failed_processing_toast", "error" to exception.toString()), exception.message)
pendingTask.fail("Failed to merge overlay")
}
mergedOverlay.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}
downloadRemoteMedia(pendingTask, downloadMetadata, downloadedMedias, downloadRequest)
}.onFailure { exception ->
pendingTask.fail("Failed to download media")
remoteSideContext.log.error("Failed to download media", exception)
callbackOnFailure(translation["failed_generic_toast"], exception.message)
}
}
}

View File

@@ -90,6 +90,12 @@ class FFMpegProcessor(
)
private val sharedExecutor = Executors.newSingleThreadExecutor()
protected fun finalize() {
runCatching { sharedExecutor.shutdown() }
}
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
@@ -106,7 +112,18 @@ class FFMpegProcessor(
if (session.returnCode.isValueSuccess) {
Result.success(session)
} else {
Result.failure(Exception(session.output))
val output = session.output
val errorMsg = when {
output.isNullOrBlank() -> "FFmpeg failed (exit code: ${session.returnCode})"
else -> {
val lines = output.lines().filter { line ->
line.isNotBlank() && !line.startsWith("ffmpeg version", ignoreCase = true) && !line.contains("Copyright")
}
lines.lastOrNull()?.take(400)
?: "FFmpeg failed. Try changing video codec in FFmpeg options (e.g. libx264)"
}
}
Result.failure(Exception(errorMsg))
}
)
}, logFunction@{ log ->
@@ -116,12 +133,7 @@ class FFMpegProcessor(
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
else -> return@logFunction
}, log.message)
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}
private fun isMediaCodecFailure(output: String): Boolean {
val lower = output.lowercase()
return lower.contains("mediacodec") || lower.contains("h264_mediacodec") || lower.contains("amediacodec")
}, { onStatistics(it) }, sharedExecutor)
}
suspend fun execute(args: Request) {
@@ -139,22 +151,27 @@ class FFMpegProcessor(
}
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "h264_mediacodec")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
}
fun applyVideoArguments() {
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
}
when (args.action) {
Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'"
}
}
Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\""
}
@@ -162,8 +179,9 @@ class FFMpegProcessor(
if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a"
}
outputArguments -= "-c:v"
args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it
} ?: run {
outputArguments += "-vn"
@@ -174,6 +192,7 @@ class FFMpegProcessor(
}
}
Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file ->
runCatching {
@@ -181,45 +200,47 @@ class FFMpegProcessor(
}.getOrNull()?.let { file to it }
}
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
try {
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
}
inputArguments += "-i" to file
}
inputArguments += "-i" to file
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]"
outputArguments += "-map" to "[aout]"
outputArguments += "-map" to "[vout]"
} finally {
filesInfo.forEach { it.second.close() }
}
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
filesInfo.forEach { it.second.close() }
}
Action.DOWNLOAD_AUDIO_STREAM -> {
outputArguments.clear()
@@ -250,25 +271,11 @@ class FFMpegProcessor(
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-filter_complex" to filterParts.toString()
outputArguments += "-map" to "[aout]"
}
}
outputArguments += args.output.absolutePath
try {
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} catch (e: Exception) {
val output = e.message.orEmpty()
val usingMediaCodec = outputArguments["-c:v"] == "h264_mediacodec"
val canRetry = ffmpegOptions.customVideoCodec.get().isEmpty()
if (usingMediaCodec && canRetry && isMediaCodecFailure(output)) {
logManager.warn("MediaCodec failed, retrying with libx264", TAG)
outputArguments -= "-c:v"
outputArguments += "-c:v" to "libx264"
newFFMpegTask(globalArguments, inputArguments, outputArguments)
} else {
throw e
}
}
newFFMpegTask(globalArguments, inputArguments, outputArguments)
}
}

View File

@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
val job: Job
val writePfd: ParcelFileDescriptor
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
if (exists()) delete()
}
@@ -120,7 +120,7 @@ class CallDownloadSessionImpl(
val pendingTask = context.taskManager.createPendingTask(
Task(
type = TaskType.DOWNLOAD,
title = "Call Recording $author",
title = context.translation.format("task_call_recording_title", "author" to author),
author = author,
hash = UUID.randomUUID().toString()
)
@@ -188,4 +188,4 @@ class CallDownloadSessionImpl(
context.log.verbose("ending call")
}
}
}
}

View File

@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -15,7 +17,11 @@ class AppDatabase(
val executor: ExecutorService = Executors.newSingleThreadExecutor()
lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
// Multi-subscriber event stream for messaging data updates
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun executeAsync(block: () -> Unit) {
executor.execute {
@@ -113,6 +119,22 @@ class AppDatabase(
"id CHAR(36) PRIMARY KEY",
"content TEXT",
),
"assistant_registry" to listOf(
"id VARCHAR PRIMARY KEY",
"kind VARCHAR",
"title VARCHAR",
"category VARCHAR",
"path TEXT",
"description TEXT",
"settingKey VARCHAR",
"screenRoute VARCHAR",
"allowedActions TEXT",
"allowedValues TEXT",
"aliases TEXT",
"commonTypos TEXT",
"examples TEXT",
"searchTokens TEXT",
),
))
}
}

View File

@@ -0,0 +1,93 @@
package me.eternal.purrfectsnap.storage
import android.content.ContentValues
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import org.json.JSONArray
data class AssistantRegistryEntry(
val id: String,
val kind: String,
val title: String,
val category: String,
val path: String,
val description: String,
val settingKey: String? = null,
val screenRoute: String? = null,
val allowedActions: List<String> = emptyList(),
val allowedValues: List<String> = emptyList(),
val aliases: List<String> = emptyList(),
val commonTypos: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val searchTokens: List<String> = emptyList()
)
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
private fun parseStringList(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}.getOrDefault(emptyList())
}
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
database.beginTransaction()
try {
database.execSQL("DELETE FROM assistant_registry")
entries.forEach { entry ->
database.insert(
"assistant_registry",
null,
ContentValues().apply {
put("id", entry.id)
put("kind", entry.kind)
put("title", entry.title)
put("category", entry.category)
put("path", entry.path)
put("description", entry.description)
put("settingKey", entry.settingKey)
put("screenRoute", entry.screenRoute)
put("allowedActions", entry.allowedActions.toJsonArrayString())
put("allowedValues", entry.allowedValues.toJsonArrayString())
put("aliases", entry.aliases.toJsonArrayString())
put("commonTypos", entry.commonTypos.toJsonArrayString())
put("examples", entry.examples.toJsonArrayString())
put("searchTokens", entry.searchTokens.toJsonArrayString())
}
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
val entries = mutableListOf<AssistantRegistryEntry>()
while (cursor.moveToNext()) {
entries += AssistantRegistryEntry(
id = cursor.getStringOrNull("id") ?: continue,
kind = cursor.getStringOrNull("kind") ?: "feature",
title = cursor.getStringOrNull("title") ?: "",
category = cursor.getStringOrNull("category") ?: "",
path = cursor.getStringOrNull("path") ?: "",
description = cursor.getStringOrNull("description") ?: "",
settingKey = cursor.getStringOrNull("settingKey"),
screenRoute = cursor.getStringOrNull("screenRoute"),
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
aliases = parseStringList(cursor.getStringOrNull("aliases")),
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
examples = parseStringList(cursor.getStringOrNull("examples")),
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
)
}
entries
}
}

View File

@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.isStealthRule
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.util.ktx.getInteger
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
@@ -86,6 +89,63 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) {
}
}
fun AppDatabase.replaceMessagingData(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
executeAsync {
database.beginTransaction()
try {
friends.forEach { friend ->
// Industrial Filter: Only update existing friends, never auto-insert new ones.
database.execSQL(
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
arrayOf<Any?>(
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId,
friend.userId
)
)
friend.streaks?.takeIf { it.length > 0 }?.also {
val streaks = getFriendStreaks(friend.userId)
database.execSQL(
"INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)",
arrayOf<Any?>(
friend.userId,
streaks?.notify != false,
it.expirationTimestamp,
it.length
)
)
} ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
}
groups.forEach { group ->
// Industrial Filter: Only update existing groups.
database.execSQL(
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
arrayOf<Any?>(
group.name,
group.participantsCount,
group.conversationId
)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
// Notify all observers with the raw sync data (AddFriendDialog needs this)
messagingDataFlow.tryEmit(friends to groups)
}
}
fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
return database.rawQuery(
"SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid)
@@ -102,12 +162,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
context.log.error("Failed to parse rule", it)
}
}
rules
rules.normalizeStealthRules().toList()
}
}
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
val ruleType = MessagingRuleType.getByName(type)
if (ruleType?.isStealthRule() == true) {
val updatedStealthRules = getRules(targetUuid)
.withNormalizedRuleToggle(ruleType, enabled)
.filter { it.isStealthRule() }
database.beginTransaction()
try {
database.execSQL(
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
arrayOf(
targetUuid,
MessagingRuleType.STEALTH.key,
MessagingRuleType.SNAP_STEALTH.key,
MessagingRuleType.CHAT_STEALTH.key
)
)
updatedStealthRules.forEach { stealthRule ->
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
arrayOf(targetUuid, stealthRule.key)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
return@executeAsync
}
if (enabled) {
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",

View File

@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setSmallIcon(R.mipmap.ic_launcher_monochrome)
.setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher))
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.ThemePreferences
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.CircularRevealOverlay
import me.eternal.purrfectsnap.ui.util.ThankYouDialog
import android.content.IntentFilter
@@ -132,16 +133,16 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = "Wrong APK installed",
title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = "Close",
confirmButtonText = managerContext.translation["setup.activity.close_button"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = "Your device is armv8, please download the armv8 apk, not armv7.",
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
@@ -212,6 +213,23 @@ class MainActivity : ComponentActivity() {
}
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay (Android 13+ only for stability)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
} else {
// Instantly clear reveal state on older versions
navigation.themeRevealState.pendingReveal?.let {
navigation.themeRevealState.clearReveal()
}
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)

View File

@@ -0,0 +1,25 @@
package me.eternal.purrfectsnap.ui.manager
import me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion.AphelionTheme
import me.eternal.purrfectsnap.ui.manager.pages.themes.legacy.LegacyTheme
/**
* ManagerTheme is the registry of all available themes.
*
* Adding a new theme requires changes only in this file:
* - Add a new object entry to the sealed class
* - Add its ID string to fromId()
*
* No other file needs to change when a new theme is added.
*/
sealed class ManagerTheme(val theme: ThemeContract) {
object Legacy : ManagerTheme(LegacyTheme)
object Aphelion : ManagerTheme(AphelionTheme)
companion object {
fun fromId(id: String): ManagerTheme = when (id) {
"APHELION" -> Aphelion
else -> Legacy // default fallback is always Legacy
}
}
}

View File

@@ -39,6 +39,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -69,12 +73,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -105,6 +104,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.theme.aphelion.ThemeRevealState
import kotlin.math.round
import kotlin.math.PI
import kotlin.math.sin
@@ -116,28 +116,44 @@ import kotlin.math.sin
androidx.compose.animation.ExperimentalAnimationApi::class
)
class Navigation(
private val context: RemoteSideContext,
internal val context: RemoteSideContext,
private val navController: NavHostController,
val routes: Routes = Routes(context).also { it.navController = navController }
) {
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
val themeRevealState = ThemeRevealState()
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
}
val haptic = LocalHapticFeedback.current
TopAppBar(
modifier = Modifier.height(headerHeight),
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer {
scaleX = 1f - (focusFactor * 0.05f)
scaleY = 1f - (focusFactor * 0.05f)
translationY = (-2 * focusFactor).dp.toPx()
}
)
}
}
@@ -146,11 +162,20 @@ class Navigation(
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
IconButton(onClick = { if (canGoBack) navController.popBackStack() }) {
IconButton(onClick = {
if (canGoBack) {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
}
@@ -162,21 +187,34 @@ class Navigation(
actions = {
currentRoute?.topBarActions?.invoke(this)
if (currentRoute?.routeInfo?.id == routes.settings.routeInfo.id) {
IconButton(onClick = { openBottomBarCustomization = true }) {
IconButton(onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openBottomBarCustomization = true
}) {
Icon(Icons.Filled.Tune, contentDescription = null)
}
}
}
)
}
@Composable
fun FloatingBottomBar() {
val haptic = LocalHapticFeedback.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val availableRoutes = remember {
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val isAphelion = context.config.root.global.uiSettings.managerTheme.get() == "APHELION"
val focusFactor = if (isAphelion) (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f) else 0f
val barHeight = lerp(82.dp, 64.dp, focusFactor)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val iconTranslationY = (10 * focusFactor).dp
val prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> {
@@ -233,9 +271,17 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
color = Color.Transparent,
color = Color.White.copy(alpha = 0.08f),
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.drawBehind {
@@ -263,7 +309,7 @@ class Navigation(
Box(
Modifier
.fillMaxWidth()
.height(82.dp)
.height(barHeight)
.clip(barShape)
.background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape)
@@ -296,16 +342,16 @@ class Navigation(
)
}
)
Box(Modifier.fillMaxWidth().height(82.dp)) {
Box(Modifier.fillMaxWidth().height(barHeight)) {
var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
val selectedIndex = remember(currentRoute, selectedRoutes) {
val index = selectedRoutes.indexOf(currentRoute)
if (index >= 0) index else null // indexOf returns -1 when not found, replace with null
if (index >= 0) index else null
}
selectedIndex?.let { // Null check
selectedIndex?.let {
val itemWidthPx =
remember(barWidthPx, itemCount) { if (itemCount > 0) barWidthPx / itemCount else 0f }
val offsetAnim = remember { Animatable(0f) }
@@ -369,7 +415,7 @@ class Navigation(
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = 10.dp, horizontal = 2.dp)
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) {
Box(
@@ -430,7 +476,10 @@ class Navigation(
contentDescription = null,
modifier = Modifier
.size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
.graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
)
},
label = {
@@ -441,11 +490,15 @@ class Navigation(
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
)
},
selected = isSelected,
@@ -456,7 +509,10 @@ class Navigation(
unselectedTextColor = Color.White.copy(alpha = 0.72f),
indicatorColor = Color.Transparent
),
onClick = { route.navigateReset() }
onClick = {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
route.navigateReset()
}
)
}
}
@@ -545,7 +601,7 @@ class Navigation(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
} else {
val haptic = LocalHapticFeedback.current
val hapticCustom = LocalHapticFeedback.current
var draggingId by remember { mutableStateOf<String?>(null) }
var dragDelta by remember { mutableStateOf(0f) }
var dragStartIndex by remember { mutableStateOf(-1) }
@@ -577,7 +633,7 @@ class Navigation(
draggingId = id
dragStartIndex = selectedTabIds.indexOf(id)
dragDelta = 0f
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
},
onDrag = { _: PointerInputChange, dragAmount ->
dragDelta += dragAmount.y
@@ -591,7 +647,7 @@ class Navigation(
list.add(targetIndex, id)
selectedTabIds = list
saveSelected(selectedTabIds)
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
hapticCustom.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
}
}
},
@@ -663,7 +719,7 @@ class Navigation(
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
) {
availableRoutes.forEach { route ->
val id = route.routeInfo.id
val already = selectedTabIds.contains(id)
@@ -794,7 +850,9 @@ class Navigation(
} else {
navigation("main_" + route.routeInfo.id, route.routeInfo.id) {
composable("main_" + route.routeInfo.id) { route.content.invoke(it) }
children.forEach { child -> composable(child.routeInfo.id) { child.content.invoke(it) } }
children.forEach { child ->
composable(child.routeInfo.id) { child.content.invoke(it) }
}
route.customComposables.invoke(this)
}
}

View File

@@ -0,0 +1,51 @@
package me.eternal.purrfectsnap.ui.manager
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@Composable
fun rememberRouteScrollState(key: String): ScrollState {
val scrollState = remember(key) {
ScrollState(RouteStateCache.scrollOffsets[key] ?: 0)
}
LaunchedEffect(key, scrollState.value) {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
DisposableEffect(key, scrollState) {
onDispose {
RouteStateCache.scrollOffsets[key] = scrollState.value
}
}
return scrollState
}
@Composable
fun rememberRouteLazyListState(key: String): LazyListState {
val savedState = RouteStateCache.lazyListOffsets[key]
val listState = remember(key) {
LazyListState(
firstVisibleItemIndex = savedState?.first ?: 0,
firstVisibleItemScrollOffset = savedState?.second ?: 0
)
}
LaunchedEffect(key, listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
DisposableEffect(key, listState) {
onDispose {
RouteStateCache.lazyListOffsets[key] =
listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}
}
return listState
}
private object RouteStateCache {
val scrollOffsets = mutableMapOf<String, Int>()
val lazyListOffsets = mutableMapOf<String, Pair<Int, Int>>()
}

View File

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

View File

@@ -0,0 +1,28 @@
package me.eternal.purrfectsnap.ui.manager
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
/**
* ThemeContract defines the visual layout contract every theme must fulfill.
*/
interface ThemeContract {
@Composable fun HomeRootSection.HomeScreen(nav: NavBackStackEntry)
@Composable fun HomeSettings.SettingsScreen(nav: NavBackStackEntry)
@Composable fun HomeAbout.AboutScreen(nav: NavBackStackEntry)
@Composable fun HomeLogs.LogsScreen(nav: NavBackStackEntry)
@Composable fun SocialRootSection.SocialScreen(nav: NavBackStackEntry)
@Composable fun TasksRootSection.TasksScreen(nav: NavBackStackEntry)
@Composable fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry)
@Composable fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry)
@Composable fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry)
}

View File

@@ -1,125 +1,284 @@
package me.eternal.purrfectsnap.ui.manager.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.zIndex
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
@Immutable
data class FloatingTopBarColors(
val container: Color,
val border: Brush
val borderStart: Color,
val borderEnd: Color
)
@Composable
fun rememberDefaultFloatingTopBarColors(): FloatingTopBarColors {
return remember {
FloatingTopBarColors(
container = Color.White.copy(alpha = 0.07f),
border = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
container = Color.White.copy(alpha = 0.12f),
borderStart = PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
borderEnd = PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
}
}
/**
* Unified Floating Top Bar for Aphelion.
* Handles the signature morphing animation and provides a "Bottom Content" slot.
*/
@Composable
fun FloatingTopBar(
title: String,
subtitle: String? = null,
onBack: (() -> Unit)? = null,
modifier: Modifier = Modifier,
scrollOffset: Int = 0,
enableMorph: Boolean = false,
containerAlpha: Float = 1f,
titleAlignment: Alignment.Horizontal = Alignment.Start,
actions: @Composable RowScope.() -> Unit = {},
bottomContent: @Composable ColumnScope.(Float) -> Unit = {},
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = shape,
color = colors.container,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, colors.border)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
val haptic = LocalHapticFeedback.current
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val focusFactor by remember(scrollOffset, enableMorph) {
derivedStateOf {
if (!enableMorph) 0f
else (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
}
}
val morphingParams by remember(focusFactor, statusBarHeight) {
derivedStateOf {
object {
val headerHeight = lerp(64.dp, 56.dp, focusFactor)
val sidePadding = lerp(14.dp, 0.dp, focusFactor)
val containerTopPadding = lerp(statusBarHeight + 4.dp, 0.dp, focusFactor)
val internalTopPadding = lerp(0.dp, statusBarHeight, focusFactor)
val internalVerticalPadding = lerp(8.dp, 0.dp, focusFactor)
val topCorners = lerp(26.dp, 0.dp, focusFactor)
val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
val subtitleAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val subtitleTranslationY = lerp(0.dp, (-10).dp, focusFactor)
val iconScale = 1f - (0.12f * focusFactor)
val horizontalShift = (6 * focusFactor).dp
}
}
}
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped && scrollOffset > 10) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.5f) {
hasSnapped = false
}
}
val shape = remember(morphingParams.topCorners, morphingParams.bottomCorners) {
RoundedCornerShape(
topStart = morphingParams.topCorners,
topEnd = morphingParams.topCorners,
bottomStart = morphingParams.bottomCorners,
bottomEnd = morphingParams.bottomCorners
)
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
val refractiveColor = remember { Color(0xFF241F52) }
Box(modifier = modifier.fillMaxWidth().zIndex(10f)) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.height(morphingParams.internalTopPadding + morphingParams.headerHeight + 32.dp)
.background(
Brush.verticalGradient(
0.0f to refractiveColor.copy(alpha = 0.95f * focusFactor),
0.6f to refractiveColor.copy(alpha = 0.85f * focusFactor),
1.0f to Color.Transparent
)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = morphingParams.sidePadding)
.padding(top = morphingParams.containerTopPadding)
.graphicsLayer {
alpha = containerAlpha
},
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = (4 * focusFactor).dp
) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
listOf(
Color(0xFF1B152E).copy(alpha = 0.85f + (0.1f * focusFactor)),
refractiveColor.copy(alpha = 0.85f + (0.1f * focusFactor))
)
)
)
}
} else {
Spacer(modifier = Modifier.height(0.dp))
}
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
val tr = morphingParams.topCorners.toPx()
val br = morphingParams.bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
uPath.apply {
moveTo(0f, 0f)
lineTo(0f, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 180f, -90f, false)
lineTo(size.width - br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 90f, -90f, false)
lineTo(size.width, 0f)
}
drawPath(uPath, brush, style = Stroke(strokeWidth))
} else {
borderPath.reset()
borderPath.apply {
moveTo(tr, 0f)
lineTo(size.width - tr, 0f)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*tr, 0f, size.width, 2*tr), 270f, 90f, false)
lineTo(size.width, size.height - br)
arcTo(androidx.compose.ui.geometry.Rect(size.width - 2*br, size.height - 2*br, size.width, size.height), 0f, 90f, false)
lineTo(br, size.height)
arcTo(androidx.compose.ui.geometry.Rect(0f, size.height - 2*br, 2*br, size.height), 90f, 90f, false)
lineTo(0f, tr)
arcTo(androidx.compose.ui.geometry.Rect(0f, 0f, 2*tr, 2*tr), 180f, 90f, false)
}
drawPath(borderPath, brush, style = Stroke(strokeWidth))
}
}
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = morphingParams.internalTopPadding)
.padding(horizontal = 16.dp, vertical = morphingParams.internalVerticalPadding)
.height(morphingParams.headerHeight),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (onBack != null) {
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onBack()
},
modifier = Modifier
.size(44.dp)
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = titleAlignment
) {
Text(
text = title,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 19.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = if (titleAlignment == Alignment.CenterHorizontally) TextAlign.Center else TextAlign.Start,
contentAlignment = if (titleAlignment == Alignment.CenterHorizontally) Alignment.Center else Alignment.CenterStart,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
translationY = morphingParams.subtitleTranslationY.toPx()
alpha = morphingParams.subtitleAlpha
}
)
}
}
Box(contentAlignment = Alignment.CenterEnd) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
content = actions
)
Row(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
scaleX = morphingParams.iconScale
scaleY = morphingParams.iconScale
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
actions()
}
}
bottomContent(focusFactor)
}
}
}
}

View File

@@ -13,11 +13,14 @@ import me.eternal.purrfectsnap.RemoteSideContext
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import okhttp3.OkHttpClient
import okhttp3.Request
object UpdateDownloader {
private const val TAG = "UpdateDownloader"
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private val fallbackHttpClient by lazy { OkHttpClient() }
private fun getInstance(context: RemoteSideContext): Fetch {
fetch?.let { return it }
@@ -84,6 +87,132 @@ object UpdateDownloader {
return downloadedFile
}
private fun scheduleReset(scope: CoroutineScope) {
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
downloadProgress.value = 0f
}
}
private fun installDownloadedFile(
remoteContext: RemoteSideContext,
downloadedFile: File,
scope: CoroutineScope
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.COMPLETED
runCatching {
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
scheduleReset(scope)
}
private fun failDownload(
remoteContext: RemoteSideContext,
scope: CoroutineScope,
errorMessage: String,
throwable: Throwable? = null
) {
val context = remoteContext.androidContext
val translation = remoteContext.translation.getCategory("manager.sections.home")
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to errorMessage),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $errorMessage", it, TAG) }
?: remoteContext.log.error("Update download failed: $errorMessage", TAG)
scheduleReset(scope)
}
private fun startHttpFallbackDownload(
remoteContext: RemoteSideContext,
downloadUrl: String,
filePath: String,
scope: CoroutineScope
) {
val partialFile = File("$filePath.part")
val outputFile = File(filePath)
scope.launch(Dispatchers.IO) {
runCatching {
remoteContext.log.warn("Fetch download failed, retrying update download via OkHttp fallback", TAG)
partialFile.parentFile?.mkdirs()
if (partialFile.exists()) partialFile.delete()
if (outputFile.exists()) outputFile.delete()
val request = Request.Builder()
.url(downloadUrl)
.header("User-Agent", "PurrfectSnap-Updater")
.build()
fallbackHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("HTTP_${response.code}")
}
val body = response.body ?: throw IllegalStateException("EMPTY_RESPONSE_BODY")
val contentLength = body.contentLength()
var downloadedBytes = 0L
body.byteStream().use { input ->
partialFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
if (contentLength > 0) {
downloadProgress.value = downloadedBytes.toFloat() / contentLength.toFloat()
}
}
}
}
}
if (!partialFile.renameTo(outputFile)) {
partialFile.copyTo(outputFile, overwrite = true)
partialFile.delete()
}
installDownloadedFile(remoteContext, outputFile, scope)
}.onFailure {
runCatching { partialFile.delete() }
failDownload(remoteContext, scope, "FALLBACK_${it.message ?: "UNKNOWN"}", it)
}
}
}
fun downloadAndInstall(
remoteContext: RemoteSideContext,
downloadUrl: String,
@@ -100,6 +229,7 @@ object UpdateDownloader {
networkType = NetworkType.ALL
}
listener?.let { fetch.removeListener(it) }
var fallbackAttempted = false
listener = object : AbstractFetchListener() {
override fun onAdded(download: Download) {
downloadState.value = DownloadState.DOWNLOADING
@@ -116,60 +246,21 @@ object UpdateDownloader {
}
override fun onCompleted(download: Download) {
downloadState.value = DownloadState.COMPLETED
runCatching {
val downloadedFile = File(download.file)
remoteContext.log.info(
"Download completed -> ${downloadedFile.absolutePath} (${downloadedFile.length()} bytes)",
TAG
)
Toast.makeText(context, translation["update_download_completed_toast"], Toast.LENGTH_SHORT).show()
val apkFile = resolveDownloadedApk(remoteContext, downloadedFile)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
remoteContext.log.info("Launching installer for ${apkFile.absolutePath}", TAG)
context.startActivity(installIntent)
scope.launch(Dispatchers.IO) {
delay(30_000)
runCatching { downloadedFile.delete() }
apkFile.parentFile
?.takeIf { it.name == "update" }
?.let { dir -> runCatching { dir.deleteRecursively() } }
remoteContext.log.info("Cleaned downloaded update files", TAG)
}
}.onFailure {
Toast.makeText(context, translation["update_install_failed_toast"], Toast.LENGTH_SHORT).show()
remoteContext.log.error("Failed to install downloaded update", it, TAG)
downloadState.value = DownloadState.FAILED
}
installDownloadedFile(remoteContext, File(download.file), scope)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
}
}
override fun onError(download: Download, error: Error, throwable: Throwable?) {
downloadState.value = DownloadState.FAILED
Toast.makeText(
context,
translation.format("update_download_failed_toast", "error" to error.toString()),
Toast.LENGTH_SHORT
).show()
throwable?.let { remoteContext.log.error("Update download failed: $error", it, TAG) }
?: remoteContext.log.error("Update download failed: $error", TAG)
fetch.removeListener(this)
scope.launch {
delay(2000)
downloadState.value = DownloadState.IDLE
if (!fallbackAttempted && error == Error.REQUEST_NOT_SUCCESSFUL) {
fallbackAttempted = true
downloadState.value = DownloadState.DOWNLOADING
downloadProgress.value = 0f
remoteContext.log.warn("Fetch returned REQUEST_NOT_SUCCESSFUL, starting fallback downloader", TAG)
startHttpFallbackDownload(remoteContext, downloadUrl, filePath, scope)
return
}
failDownload(remoteContext, scope, error.toString(), throwable)
}
}
fetch.addListener(listener!!)

View File

@@ -113,8 +113,8 @@ object Updater {
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
} else {
fetchLatestRelease(channel)
}

View File

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

View File

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

View File

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

View File

@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -41,6 +38,7 @@ import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.storage.clearRuleIds
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.storage.setRule
@@ -139,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key)
}
val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) {
ruleState = newState
@@ -162,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
fun showAddFriendDialog() {
addFriendDialog = AddFriendDialog(
context = context,
pinnedIds = currentRuleIds,
pinnedIds = currentRuleIds.toList(),
actionHandler = Actions(
onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(friend.userId)
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else {
currentRuleIds.remove(friend.userId)
}
@@ -175,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(group.conversationId)
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else {
currentRuleIds.remove(group.conversationId)
}
},
getFriendState = { friend ->
currentRuleIds.contains(friend.userId)
currentRuleIdSet.contains(friend.userId)
},
getGroupState = { group ->
currentRuleIds.contains(group.conversationId)
currentRuleIdSet.contains(group.conversationId)
}
)
)
@@ -203,8 +204,8 @@ class ManageRuleFeature : Routes.Route() {
title = translation["clear_list_button"],
text = translation["dialog_clear_confirmation_text"],
icon = Icons.Default.DeleteSweep,
confirmButtonText = context.translation["clear"],
dismissButtonText = context.translation["button.cancel"],
confirmButtonText = translation["dialog_clear_confirm_button"],
dismissButtonText = translation["dialog_clear_cancel_button"],
onDismiss = { confirmationDialog = false },
onConfirm = {
context.database.clearRuleIds(currentRuleType.key)
@@ -229,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.zIndex(10f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
.padding(top = topBarHeight + 10.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
item {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()],
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
}
}
}
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"],
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
item {
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"] ?: "Disabled",
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
}
}
val manageLabel = when (ruleState) {
@@ -290,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
else -> null
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"],
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"] ?: "Whitelist",
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
) {
Text(text = translation["whitelist_state_button"])
}
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"],
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"])
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = context.translation["clear"])
Text(text = translation["whitelist_state_button"] ?: "Manage")
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"] ?: "Blacklist",
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"] ?: "Manage")
}
}
}
item {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"] ?: "Clear List",
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
}
}
}
}
item {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
}

View File

@@ -1,343 +1,143 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import android.os.SystemClock
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"] }
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"])
context.shortToast(translation["about_magic_toast"] ?: "Tap 5 times in this screen to see some magic 😉!")
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"],
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(30.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.panelGradient)
.padding(horizontal = 22.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = translation["about_title"],
fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold,
color = PurrfectPalette.textPrimary,
fontFamily = avenirNext,
modifier = Modifier.clickable(
interactionSource = tapSource,
indication = null
) {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
}
)
Text(
text = translation["about_tagline"],
fontSize = 13.sp,
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["about_lead_developers_title"],
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier.padding(top = 10.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(
name = "ΞTΞRNAL",
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = "<RSR/>",
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = translation["about_story_title"],
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = aboutStory,
fontSize = 14.sp,
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp
)
}
}
Spacer(modifier = Modifier.height(14.dp))
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = translation["about_thanks_title"],
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
modifier = Modifier.weight(1f),
onClick = {
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap",
context.translation["toast_open_link_failed"]
)
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_github),
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["github_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = {
context.androidContext.openLink(
"https://t.me/purrfectsnap_official",
context.translation["toast_open_link_failed"]
)
},
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.35f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram),
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["telegram_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeAbout.AboutScreen(nav)
}
}
}
@Composable
private fun DeveloperCard(
internal fun DeveloperCard(
name: String,
imageRes: Int,
avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val cardShape = RoundedCornerShape(20.dp)
val imageRing = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary,
PurrfectPalette.glowSecondary
)
)
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Surface(
modifier = modifier,
shape = cardShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
onClick = {
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
}
tapCount.intValue += 1
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
routes.retroGame.navigate()
}
},
modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
interactionSource = tapSource,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 16.dp),
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(82.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape)
Surface(
modifier = Modifier.size(64.dp),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(2.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary)))
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
Text(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}

View File

@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Report
@@ -64,6 +65,7 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -73,14 +75,14 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0)
internal val logListState = LazyListState()
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
internal val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
private fun clearLogsAndReload() {
internal fun clearLogsAndReload() {
context.coroutineScope.launch {
context.log.clearLogs()
withContext(Dispatchers.Main) {
@@ -89,7 +91,7 @@ class HomeLogs : Routes.Route() {
}
}
private fun exportLogs() {
internal fun exportLogs() {
activityLauncherHelper.saveFile("purrfectsnap-logs-${System.currentTimeMillis()}.zip", "application/zip") { uri ->
context.coroutineScope.launch {
context.shortToast(translation["saving_logs_toast"])
@@ -107,118 +109,13 @@ class HomeLogs : Routes.Route() {
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
visibleLogs.add(line)
}
}
}
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"])
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
val filteredLogs = withContext(Dispatchers.IO) {
(0 until reader.lineCount).mapNotNull { index ->
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
}
}
visibleLogs.clear()
visibleLogs.addAll(filteredLogs)
}
delay(220)
if (visibleLogs.isNotEmpty()) {
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
logListState.scrollToItem(targetIndex)
}
isRefreshing = false
}
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
isRefreshing = true
refreshLogs()
}
}
val pullRefreshState = rememberPullRefreshState(isRefreshing, onRefresh = {
isRefreshing = true
refreshLogs()
})
LaunchedEffect(Unit) {
isRefreshing = true
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
LogsFloatingBar(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
refreshLogs()
},
onExport = { exportLogs() },
onClear = { clearLogsAndReload() }
)
Spacer(modifier = Modifier.height(12.dp))
Surface(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 12.dp,
bottom = routes.bottomPadding + 22.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp)
)
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) { delay(300); value = context.config.root.global.uiSettings.managerTheme.get() }
}
key(themeId) { with(ManagerTheme.fromId(themeId).theme) { this@HomeLogs.LogsScreen(nav) } }
}
override val floatingActionButton: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
Column(
@@ -271,20 +168,20 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogsFloatingBar(
internal fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onFilter: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
var showDropDown by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var showMenu by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding),
shape = RoundedCornerShape(28.dp),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
@@ -292,87 +189,107 @@ class HomeLogs : Routes.Route() {
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"] ?: "Logs",
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = translation["common.back"],
tint = Color.White
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectPalette.glowSecondary
Text(
text = translation["manager.routes.home_logs"] ?: "Logs",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = onFilter) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filter Logs",
tint = PurrfectPalette.glowSecondary
)
}
IconButton(onClick = onRefresh, enabled = !isRefreshing) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = translation["refresh_button_description"],
tint = Color.White
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = null,
tint = Color.White
)
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
shape = RoundedCornerShape(14.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp
) {
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = null,
tint = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
onExport()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
},
text = { Text(text = translation["export_logs_button"] ?: "Export Logs", color = Color.White) },
onClick = {
onExport()
showMenu = false
}
)
DropdownMenuItem(
leadingIcon = {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = null,
tint = Color(0xFFFF9CAB)
)
)
}
},
text = { Text(text = translation["clear_logs_button"] ?: "Clear Logs", color = Color.White) },
onClick = {
onClear()
showMenu = false
}
)
}
}
}
@@ -381,7 +298,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun EmptyLogsState() {
internal fun EmptyLogsState() {
Column(
modifier = Modifier
.fillMaxSize()
@@ -412,7 +329,7 @@ class HomeLogs : Routes.Route() {
textAlign = TextAlign.Center
)
Text(
text = "Pull to refresh or trigger an action to see new entries.",
text = translation["refresh_hint"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp,
textAlign = TextAlign.Center,
@@ -422,8 +339,7 @@ class HomeLogs : Routes.Route() {
}
@Composable
private fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
// Normalize overly fragmented log text (some entries were rendered with one character per line)
internal fun LogEntryCard(line: LogLine, composeContext: android.content.Context) {
val normalizedMessage = remember(line.message) {
val cleaned = line.message.replace("\r", "")
val fragments = cleaned.lines()
@@ -534,14 +450,14 @@ class HomeLogs : Routes.Route() {
}
}
private fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
internal fun logLevelColor(logLevel: LogLevel): Color = when (logLevel) {
LogLevel.DEBUG -> PurrfectPalette.glowSecondary
LogLevel.INFO, LogLevel.VERBOSE -> Color(0xFFA3F0C2)
LogLevel.WARN -> Color(0xFFFFD782)
LogLevel.ERROR, LogLevel.ASSERT -> Color(0xFFFF9CAB)
}
private fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
internal fun logLevelLabel(logLevel: LogLevel): String = when (logLevel) {
LogLevel.DEBUG -> "Debug"
LogLevel.INFO -> "Info"
LogLevel.VERBOSE -> "Verbose"
@@ -550,14 +466,38 @@ class HomeLogs : Routes.Route() {
LogLevel.ASSERT -> "Assert"
}
private fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
internal fun logLevelIcon(logLevel: LogLevel) = when (logLevel) {
LogLevel.DEBUG -> Icons.Outlined.BugReport
LogLevel.ERROR, LogLevel.ASSERT -> Icons.Outlined.Report
LogLevel.INFO, LogLevel.VERBOSE -> Icons.Outlined.Info
LogLevel.WARN -> Icons.Outlined.Warning
}
private fun shouldHideLog(line: LogLine): Boolean {
enum class LogCategory(val translationKey: String, val tags: List<String>) {
CORE("log_category_core", listOf("core", "hook", "module", "mappings")),
AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")),
MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")),
BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")),
SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")),
TRACKER("log_category_tracker", listOf("tracker", "friendtracker"))
}
val enabledCategories = mutableStateMapOf<LogCategory, Boolean>().apply {
LogCategory.entries.forEach { put(it, true) }
}
internal fun getCategoryForLog(line: LogLine): LogCategory? {
val tag = line.tag.lowercase()
val message = line.message.lowercase()
return LogCategory.entries.find { category ->
category.tags.any { tag.contains(it) || message.contains("[$it]") }
}
}
internal fun shouldHideLog(line: LogLine): Boolean {
val category = getCategoryForLog(line)
if (category != null && enabledCategories[category] == false) return true
val message = line.message.lowercase()
val tag = line.tag.lowercase()
return message.startsWith("blocked ep") ||

View File

@@ -46,13 +46,13 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Schedule
@@ -82,6 +82,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -92,6 +94,8 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
@@ -122,6 +126,9 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -137,6 +144,7 @@ class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
companion object {
internal const val QUICK_TILES_INITIALIZED_PREF = "quick_tiles_initialized"
val cardMargin = 10.dp
val pageBackgroundGradient = Brush.verticalGradient(
listOf(
@@ -147,23 +155,23 @@ class HomeRootSection : Routes.Route() {
)
}
private val changelogClient by lazy { OkHttpClient() }
private val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
private val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
private val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
internal val changelogClient by lazy { OkHttpClient() }
internal val changelogStableUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-stable.txt"
internal val changelogPrereleaseUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/changelogs-prerelease.txt"
internal val announcementsUrl = "https://raw.githubusercontent.com/particle-box/PurrfectSnap/dev/announcements.txt"
private val heroGradientColors = listOf(
internal val heroGradientColors = listOf(
Color(0xFF5C4B99),
Color(0xFF322B5E),
Color(0xFF1B1836)
)
private val quickActionsGradientColors = listOf(
internal val quickActionsGradientColors = listOf(
Color(0xFF241C3E),
Color(0xFF151127)
)
private lateinit var activityLauncherHelper: ActivityLauncherHelper
data class QaCard(val id: String, val name: String, val icon: ImageVector, val action: (Routes) -> Unit)
private val cardEntries by lazy {
internal val cardEntries by lazy {
val list = mutableListOf<QaCard>()
EnumQuickActions.entries.forEach { q ->
val name = context.translation["actions.${q.key}.name"]
@@ -175,7 +183,7 @@ class HomeRootSection : Routes.Route() {
}
list
}
private val cards by lazy {
internal val cards by lazy {
EnumQuickActions.entries.map {
(context.translation["actions.${it.key}.name"] to it.icon) to it.action
}.associate {
@@ -190,7 +198,7 @@ class HomeRootSection : Routes.Route() {
}
@Composable
private fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
internal fun rememberPreferenceBool(key: String, default: Boolean = false): State<Boolean> {
val prefs = remember { context.sharedPreferences }
val state = remember { mutableStateOf(prefs.getBoolean(key, default)) }
DisposableEffect(prefs, key) {
@@ -213,13 +221,17 @@ class HomeRootSection : Routes.Route() {
onClick: (() -> Unit)? = null,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
haptic: HapticFeedback? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
val clickModifier = if (onClick != null) {
Modifier.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
) { onClick() }
) {
haptic?.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
}
} else {
Modifier
}
@@ -243,7 +255,7 @@ class HomeRootSection : Routes.Route() {
}
@Composable
private fun HeroBadge(text: String) {
internal fun HeroBadge(text: String) {
Text(
text = text,
color = Color.White,
@@ -261,27 +273,31 @@ class HomeRootSection : Routes.Route() {
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = contentDescription, tint = Color.White)
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = it,
color = Color.White,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -313,13 +329,20 @@ class HomeRootSection : Routes.Route() {
@Composable
private fun RowScope.HomeActionChips() {
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT
)
TopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"]
label = context.translation["manager.routes.home_logs"],
modifier = Modifier
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"]
label = translation["manager.routes.home_about"],
modifier = Modifier
) { routes.about.navigate() }
}
@@ -408,7 +431,7 @@ class HomeRootSection : Routes.Route() {
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onWikiClick: () -> Unit,
onWebsiteClick: () -> Unit,
onTelegramClick: () -> Unit,
onGithubClick: () -> Unit,
authorName: String,
@@ -623,7 +646,7 @@ class HomeRootSection : Routes.Route() {
onClick = onManageClick,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
modifier = Modifier.align(Alignment.CenterHorizontally)
modifier = Alignment.CenterHorizontally.let { Modifier.align(it) }
) {
Icon(Icons.Filled.Settings, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(6.dp))
@@ -649,15 +672,15 @@ class HomeRootSection : Routes.Route() {
) {
Button(
modifier = Modifier.weight(1f),
onClick = onWikiClick,
onClick = onWebsiteClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null, modifier = Modifier.size(18.dp))
Icon(Icons.Filled.Language, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["wiki_button"], maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = "Site", maxLines = 1, overflow = TextOverflow.Ellipsis)
}
OutlinedButton(
modifier = Modifier.weight(1f),
@@ -685,7 +708,7 @@ class HomeRootSection : Routes.Route() {
}
}
}
private fun resolveTileKey(name: String): String {
internal fun resolveTileKey(name: String): String {
val entry = cardEntries.firstOrNull { it.name == name }
return entry?.id ?: name
}
@@ -703,13 +726,13 @@ class HomeRootSection : Routes.Route() {
val key = resolveTileKey(name)
prefs.edit().putString("quick_tile_size_$key", "${w.coerceIn(1,3)}x${h.coerceIn(1,3)}").apply()
}
private fun clearTileSpan(name: String) {
internal fun clearTileSpan(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_size_$key").apply()
}
private fun clearTileOffset(name: String) {
internal fun clearTileOffset(name: String) {
val prefs = context.sharedPreferences
val key = resolveTileKey(name)
prefs.edit().remove("quick_tile_offset_$key").apply()
@@ -723,606 +746,30 @@ class HomeRootSection : Routes.Route() {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.wrapContentWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
}
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
}
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) {
context.database.getQuickTiles().filter { it.isNotBlank() }
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] else translation["channel_label_stable"]
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
Updater.getLatestRelease(channel)
}
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
val coroutineScope = rememberCoroutineScope()
val isPurrAuraActive by rememberPreferenceBool("debug_test_mode", true)
var showChangelogDialog by remember { mutableStateOf(false) }
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by remember { mutableStateOf(false) }
var announcementsLoading by remember { mutableStateOf(false) }
var announcementsError by remember { mutableStateOf<String?>(null) }
var announcementsText by remember { mutableStateOf<String?>(null) }
val handleUpdateAction: () -> Unit = {
latestUpdate?.let { latest ->
val supportedAbis = android.os.Build.SUPPORTED_ABIS
var abiName: String? = null
for (abi in supportedAbis) {
when (abi) {
"arm64-v8a" -> {
abiName = "arm64"
break
}
"armeabi-v7a" -> {
abiName = "armv7"
break
}
}
}
context.log.info(
"Update request: device ABIs=${supportedAbis.joinToString()} resolvedArch=${abiName ?: "unknown"}",
"HomeRoot"
)
if (latest.workflowId != null) {
if (abiName == null) {
android.widget.Toast.makeText(
context.androidContext,
translation["update_arch_not_supported_toast"],
android.widget.Toast.LENGTH_LONG
).show()
} else {
val artifactName = "purrfectsnap-${abiName}-debug"
val downloadUrl = "https://nightly.link/particle-box/PurrfectSnap/actions/runs/${latest.workflowId}/$artifactName.zip"
context.log.info("Debug update -> downloading $artifactName from $downloadUrl", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, downloadUrl, "$artifactName.zip", coroutineScope)
}
return@let
}
val releaseDownload = abiName?.let { arch -> latest.assetDownloads[arch] }
if (releaseDownload != null) {
val fileName = releaseDownload.substringAfterLast('/')
context.log.info("Release update -> arch=$abiName url=$releaseDownload file=$fileName", "HomeRoot")
UpdateDownloader.downloadAndInstall(context, releaseDownload, fileName, coroutineScope)
} else {
context.log.warn(
"No matching update asset for arch=$abiName (available: ${latest.assetDownloads.keys})",
"HomeRoot"
)
context.androidContext.openLink(
latest.releaseUrl,
context.translation["toast_open_link_failed"]
)
}
}
}
fun loadChangelog(targetVersion: String, url: String) {
if (changelogVersion == targetVersion && changelogText != null) return
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
val body = response.body?.string() ?: throw IllegalStateException("Empty changelog body")
extractChangelogForVersion(body, targetVersion).ifBlank { body.trim() }
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
changelogText = text
changelogVersion = targetVersion
changelogLoading = false
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
changelogError = error.message ?: "Failed to load changelog"
changelogLoading = false
}
}
}
}
fun loadAnnouncements() {
if (announcementsText != null) return
announcementsLoading = true
announcementsError = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
changelogClient.newCall(Request.Builder().url(announcementsUrl).build()).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch announcements (${response.code})")
val body = response.body?.string() ?: throw IllegalStateException("Empty announcements body")
body.trim()
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
announcementsText = text
announcementsLoading = false
}
}.onFailure { error ->
withContext(Dispatchers.Main) {
announcementsError = error.message ?: "Failed to load announcements"
announcementsLoading = false
}
}
}
}
LaunchedEffect(Unit) {
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
context.sharedPreferences.edit()
.putBoolean("show_changelog_on_launch", false)
.remove("changelog_version_on_launch")
.apply()
version?.let {
showChangelogDialog = true
loadChangelog(it, changelogUrl)
}
}
}
LaunchedEffect(Unit) {
if (context.sharedPreferences.getBoolean("show_announcements_on_launch", false)) {
context.sharedPreferences.edit()
.putBoolean("show_announcements_on_launch", false)
.apply()
showAnnouncementsDialog = true
loadAnnouncements()
}
}
val onUpdateButtonClick: () -> Unit = {
latestUpdate?.let {
showChangelogDialog = true
loadChangelog(it.versionName, changelogUrl)
}
}
var showQuickActionsMenu by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navigationBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val contentBottomPadding = routes.bottomPadding + navigationBarPadding + 96.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(pageBackgroundGradient)
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = contentBottomPadding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(WindowInsets.statusBars.asPaddingValues())
.padding(horizontal = cardMargin, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
TopBarActionChip(
icon = Icons.Filled.Notifications,
label = null,
contentDescription = translation["announcements_button_description"]
) {
showAnnouncementsDialog = true
loadAnnouncements()
}
}
Row(
modifier = Modifier.wrapContentWidth(Alignment.End),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeActionChips()
}
}
Spacer(modifier = Modifier.height(12.dp))
HeroSection(
versionName = BuildConfig.VERSION_NAME,
latestUpdate = latestUpdate,
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = onUpdateButtonClick,
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onWikiClick = {
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap/wiki",
context.translation["toast_open_link_failed"]
)
},
onTelegramClick = {
context.androidContext.openLink(
"https://t.me/purrfectsnap_official",
context.translation["toast_open_link_failed"]
)
},
onGithubClick = {
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap",
context.translation["toast_open_link_failed"]
)
},
authorName = "ETERNAL",
onManageClick = { routes.settings.navigate() },
avenirNext = avenirNext,
)
Spacer(modifier = Modifier.height(12.dp))
AnimatedContent(targetState = selectedTiles.isNotEmpty(), label = "QuickActionsAnim") { hasQuickActions ->
val quickCardShape = RoundedCornerShape(34.dp)
Surface(
modifier = Modifier
.padding(horizontal = cardMargin, vertical = 10.dp),
shape = quickCardShape,
tonalElevation = 0.dp,
shadowElevation = 24.dp,
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Brush.linearGradient(quickActionsGradientColors))
.padding(horizontal = 24.dp, vertical = 28.dp)
.padding(bottom = navigationBarPadding + 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!hasQuickActions) {
Text(
translation["quick_actions_title"],
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Outlined.Widgets,
contentDescription = translation["quick_actions_icon_description"],
modifier = Modifier.size(72.dp),
tint = Color.White
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = translation["quick_actions_empty_title"],
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = translation["quick_actions_empty_subtitle"],
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = { showQuickActionsMenu = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF1B152E)
)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = translation["add_quick_action_description"],
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = translation["quick_actions_add_tile_button"])
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
translation["quick_actions_title"],
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White,
maxLines = 3,
overflow = TextOverflow.Clip
)
Text(
text = translation.format("quick_actions_count_label", "count" to selectedTiles.size.toString()),
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { showQuickActionsMenu = true },
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_manage),
contentDescription = translation["manage_quick_actions_description"],
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text = translation["quick_actions_manage_button"])
}
}
}
val spacing = 12.dp
val gridPadding = 8.dp
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val preferredTileWidth = 100.dp
val columns = ((maxWidth + spacing) / (preferredTileWidth + spacing))
.toInt()
.coerceAtLeast(2)
.coerceAtMost(4)
val computedWidth = (maxWidth - gridPadding * 2 - spacing * (columns - 1)) / columns
val tileWidth = if (computedWidth < preferredTileWidth) computedWidth else preferredTileWidth
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(all = gridPadding),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalArrangement = Arrangement.spacedBy(spacing),
maxItemsInEachRow = columns
) {
selectedTiles.forEach { tileName ->
val cardEntry = cards.entries.find { entry -> entry.key.first == tileName } ?: return@forEach
val (card, action) = cardEntry
val interactionSource = remember { MutableInteractionSource() }
Surface(
modifier = Modifier
.width(tileWidth)
.aspectRatio(1.05f)
.scaleOnPress(interactionSource)
.clickable { action(routes) },
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
PurrfectPalette.glowSecondary.copy(alpha = 0.22f)
)
)
)
.clipToBounds()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(all = 10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = card.second, contentDescription = null,
tint = Color.White,
modifier = Modifier.size(44.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = card.first,
lineHeight = 16.sp,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = Color.White,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
if (showChangelogDialog && latestUpdate != null) {
AestheticDialog(
onDismissRequest = { showChangelogDialog = false },
title = "Changelog",
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Update",
onConfirm = {
showChangelogDialog = false
handleUpdateAction()
},
dismissButtonText = "Cancel",
onDismiss = { showChangelogDialog = false },
confirmEnabled = !changelogLoading,
customContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 340.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
when {
changelogLoading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = Color.White
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading changelog…",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
changelogError != null -> {
Text(
text = changelogError ?: "Failed to load changelog",
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = changelogText ?: "Changelog not available",
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
}
}
)
}
if (showAnnouncementsDialog) {
AestheticDialog(
onDismissRequest = { showAnnouncementsDialog = false },
title = "Announcements",
text = "",
icon = Icons.Filled.Info,
confirmButtonText = "Close",
onConfirm = { showAnnouncementsDialog = false },
confirmEnabled = !announcementsLoading,
customContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 340.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
when {
announcementsLoading -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 3.dp,
color = Color.White
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Loading announcements...",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
announcementsError != null -> {
Text(
text = announcementsError ?: "Failed to load announcements",
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.SemiBold
)
}
else -> {
Text(
text = announcementsText ?: "Announcements not available",
color = PurrfectPalette.textPrimary,
fontSize = 14.sp,
lineHeight = 20.sp
)
}
}
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,
selectedQuickActions = selectedTiles,
onDismiss = { showQuickActionsMenu = false },
onSave = { newList ->
val previous = selectedTiles.toList()
val removed = previous.filter { it !in newList }
removed.forEach { clearTileSpan(it); clearTileOffset(it) }
newList.forEach { clearTileOffset(it) }
selectedTiles.clear()
selectedTiles.addAll(newList)
context.coroutineScope.launch {
context.database.setQuickTiles(selectedTiles)
}
showQuickActionsMenu = false
},
translation = translation
)
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@HomeRootSection.HomeScreen(nav)
}
}
}
}
private fun extractChangelogForVersion(raw: String, version: String): String {
internal fun extractChangelogForVersion(raw: String, version: String): String {
val lines = raw.lines()
val headerRegex = Regex("^\\s*#+\\s*v?${Regex.escape(version)}\\b", RegexOption.IGNORE_CASE)
val collected = mutableListOf<String>()
@@ -1341,3 +788,7 @@ private fun extractChangelogForVersion(raw: String, version: String): String {
}
}

View File

@@ -16,7 +16,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -32,8 +34,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
@@ -43,7 +43,9 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -51,6 +53,8 @@ import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
@@ -65,40 +69,15 @@ import java.util.concurrent.TimeUnit
class HomeSettings : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_settings") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
private val dialogs by lazy { AlertDialogs(context.translation) }
private fun scheduleUpdateCheck() {
internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
"monthly" -> 30L
else -> 1L
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
@@ -108,10 +87,10 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.putString("update_channel", "stable")
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()
@@ -125,11 +104,31 @@ class HomeSettings : Routes.Route() {
workManager.cancelUniqueWork("purrfectsnap_update_check")
}
}
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
val currentTheme = ManagerTheme.fromId(themeId).theme
with(currentTheme) {
this@HomeSettings.SettingsScreen(nav)
}
}
}
@Composable
private fun RowTitle(title: String) {
internal fun RowTitle(title: String) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
@@ -138,16 +137,22 @@ class HomeSettings : Routes.Route() {
color = Color.White
)
}
@Composable
private fun PremiumPreferenceToggle(
internal fun PremiumPreferenceToggle(
sharedPreferences: SharedPreferences,
key: String,
text: String,
defaultValue: Boolean = false
defaultValue: Boolean = false,
confirmDisableTitle: String? = null,
confirmDisableText: String? = null
) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, defaultValue)) }
var showDisableDialog by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
LaunchedEffect(realKey) {
if (!sharedPreferences.contains(realKey)) {
@@ -156,6 +161,24 @@ class HomeSettings : Routes.Route() {
}
}
if (showDisableDialog) {
AestheticDialog(
onDismissRequest = { showDisableDialog = false },
title = confirmDisableTitle ?: translation["reset_setup_dialog_title"],
text = confirmDisableText.orEmpty(),
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
value = false
sharedPreferences.edit().putBoolean(realKey, false).apply()
showDisableDialog = false
},
onDismiss = { showDisableDialog = false },
showCloseButton = false
)
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -164,11 +187,15 @@ class HomeSettings : Routes.Route() {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
value = !value
sharedPreferences
.edit() {
val nextValue = !value
if (!nextValue && confirmDisableTitle != null) {
showDisableDialog = true
} else {
value = nextValue
sharedPreferences.edit() {
putBoolean(realKey, value)
}
}
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -184,7 +211,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
internal fun PreferenceToggle(sharedPreferences: SharedPreferences, key: String, text: String) {
val realKey = "debug_$key"
var value by remember { mutableStateOf(sharedPreferences.getBoolean(realKey, false)) }
val hapticFeedback = LocalHapticFeedback.current
@@ -216,7 +243,7 @@ class HomeSettings : Routes.Route() {
}
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
internal fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
var confirmationDialog by remember {
mutableStateOf(false)
}
@@ -264,8 +291,9 @@ class HomeSettings : Routes.Route() {
}
}
}
@Composable
private fun ShiftedRow(
internal fun ShiftedRow(
modifier: Modifier = Modifier,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
@@ -278,643 +306,52 @@ class HomeSettings : Routes.Route() {
) { content(this) }
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val sharedButtonColors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.12f),
@Composable
internal fun GlassCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
contentColor = Color.White
)
val sharedOutlinedColors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
@Composable
fun GlassCard(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
contentColor = Color.White
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
content()
}
}
}
@Composable
fun AestheticDropdownField(
value: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.clip(shape)
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = value, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
contentColor = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Text(
text = translation["manager.routes.home_settings"],
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
GlassCard {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
RowAction(key = enumAction.key) { context.launchActionIntent(enumAction) }
}
RowAction(key = "regen_mappings") { context.checkForRequirements(Requirements.MAPPINGS) }
RowAction(key = "change_language") { context.checkForRequirements(Requirements.LANGUAGE) }
}
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"])
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
}
GlassCard {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"])
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier
.fillMaxWidth()
.padding(horizontal = 26.dp)
ExposedDropdownMenuBox(
expanded = channelMenuExpanded,
onExpandedChange = { channelMenuExpanded = it },
modifier = spacingModifier
) {
AestheticDropdownField(
value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel,
expanded = channelMenuExpanded,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
onClick = { channelMenuExpanded = true }
)
ExposedDropdownMenu(
expanded = channelMenuExpanded,
onDismissRequest = { channelMenuExpanded = false }
) {
listOf("stable", "prerelease").forEach { channel ->
DropdownMenuItem(
text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) },
onClick = {
selectedChannel = channel
channelMenuExpanded = false
context.config.root.global.updateSettings.updateChannel.set(channel)
context.config.writeConfig()
scheduleUpdateCheck()
}
)
}
}
}
}
}
}
GlassCard {
RowTitle(title = "Reset PurrfectSnap")
ShiftedRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
// Clear setup progress and route back to SetupActivity
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
// Clear config to defaults
context.config.reset()
context.config.writeConfig()
// Launch setup activity fresh
val intent = android.content.Intent(context.androidContext, me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
// Close current manager activity
routes.navController.popBackStack()
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Reset and restart setup",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Reset",
modifier = Modifier.padding(end = 14.dp)
)
}
}
GlassCard {
RowTitle(title = translation["message_logger_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredMessageCount()
}
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) {
context.messageLogger.getStoredStoriesCount()
}
var showImportDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val summary = translation.format(
"message_logger_summary",
"messageCount" to storedMessagesCount.toString(),
"storyCount" to storedStoriesCount.toString()
).replace("\n", " | ")
Text(
summary,
maxLines = 2,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
FlowRow(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { outputStream ->
context.messageLogger.databaseFile.inputStream().use { inputStream ->
inputStream.copyTo(outputStream)
}
}
}
}.onFailure {
context.log.error("Failed to export database", it)
context.longToast(translation.format("export_database_failed_toast", "message" to (it.localizedMessage ?: "")))
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["export_button"])
}
Button(
onClick = {
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
val tempFile = File(context.androidContext.cacheDir, "view_message_logger.db")
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
routes.viewLoggerHistory.navigate {
put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8"))
}
}
}.onFailure {
context.log.error("Failed to open file", it)
context.longToast(
translation.format(
"open_file_failed_toast",
"message" to (it.localizedMessage ?: "")
)
)
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["view_button"])
}
Button(
onClick = {
runCatching {
context.messageLogger.purgeAll()
storedMessagesCount = 0
storedStoriesCount = 0
}.onFailure {
context.log.error("Failed to clear messages", it)
context.longToast(translation.format("clear_messages_failed_toast", "message" to (it.localizedMessage ?: "")))
}.onSuccess {
context.shortToast(translation["success_toast"])
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["clear_button"])
}
Button(
onClick = { showImportDialog = true },
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = "Import")
}
}
}
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
onClick = { routes.loggerHistory.navigate() },
colors = sharedOutlinedColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))
) {
Text(translation["view_logger_history_button"])
}
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import message logger") },
text = { Text("Importing will override your current message logger database. Continue?") },
confirmButton = {
TextButton(onClick = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
runCatching {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { inputStream ->
context.messageLogger.databaseFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Unable to open selected file")
storedMessagesCount = context.messageLogger.getStoredMessageCount()
storedStoriesCount = context.messageLogger.getStoredStoriesCount()
context.shortToast(translation["success_toast"])
context.log.info("Imported message logger from $uri", "MessageLogger")
}.onFailure {
context.log.error("Failed to import message logger", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
)
}
}
}.onFailure {
context.log.error("Failed to launch import picker", it)
context.longToast(
translation.format(
"import_failed_toast",
"message" to (it.localizedMessage ?: it.message ?: "")
)
)
}
}) {
Text(translation["button.import"] ?: "Import")
}
},
dismissButton = {
TextButton(onClick = { showImportDialog = false }) {
Text(translation["button.cancel"])
}
}
)
}
}
}
GlassCard {
RowTitle(title = translation["friend_notes_title"])
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = translation["friend_notes_description"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp),
color = Color.White,
textAlign = TextAlign.Center
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = {
runCatching {
val notes = context.database.getAllScopeNotes()
if (notes.isEmpty()) {
context.shortToast(translation["friend_notes_no_notes_to_backup"])
return@runCatching
}
val json = context.gson.toJson(notes)
activityLauncherHelper.saveFile("friend_notes_backup.json", "application/json") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use {
it.write(json.toByteArray())
}
context.shortToast(translation["friend_notes_backup_success"])
}
}.onFailure {
context.log.error("Failed to backup notes", it)
context.longToast(translation.format("friend_notes_backup_failure", "error" to (it.localizedMessage ?: "")))
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["backup_button"])
}
Button(onClick = {
runCatching {
activityLauncherHelper.openFile("application/json") { uri ->
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use {
val json = it.reader().readText()
val notes = context.gson.fromJson<Map<String, String>>(json, object : com.google.gson.reflect.TypeToken<Map<String, String>>() {}.type)
context.database.setAllScopeNotes(notes)
context.shortToast(translation["friend_notes_restore_success"])
}
}
}.onFailure {
context.log.error("Failed to restore notes", it)
context.longToast(translation.format("friend_notes_restore_failure", "error" to (it.localizedMessage ?: "")))
}
},
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(text = translation["restore_button"])
}
}
}
}
}
GlassCard {
RowTitle(title = translation["debug_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)
) {
var selectedFileType by remember { mutableStateOf(InternalFileHandleType.entries.first()) }
Box(modifier = Modifier.weight(1f)) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.fillMaxWidth()
) {
AestheticDropdownField(
value = translation.getOrNull("debug_file_${selectedFileType.name.lowercase()}") ?: selectedFileType.fileName,
expanded = expanded,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(MenuAnchorType.PrimaryNotEditable),
onClick = { expanded = true }
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
InternalFileHandleType.entries.forEach { fileType ->
DropdownMenuItem(onClick = {
expanded = false
selectedFileType = fileType
}, text = {
Text(text = translation.getOrNull("debug_file_${fileType.name.lowercase()}") ?: fileType.fileName)
})
}
}
}
}
Button(
onClick = {
runCatching {
context.coroutineScope.launch {
selectedFileType.resolve(context.androidContext).delete()
}
}.onFailure {
context.log.error("Failed to clear file", it)
context.longToast(translation.format("clear_file_failed_toast", "message" to (it.localizedMessage ?: "")))
}.onSuccess {
context.shortToast(translation["success_toast"])
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.1f),
contentColor = Color.White
),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
shape = RoundedCornerShape(14.dp)
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(translation["clear_button"])
}
}
ShiftedRow {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
}
content()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AestheticDropdownField(
value: String,
expanded: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Row(
modifier = modifier
.clip(shape)
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.clickable { onClick() }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = value, color = Color.White)
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,22 +20,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
@@ -44,6 +38,7 @@ import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.input.pointer.pointerInteropFilter
@@ -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,

View File

@@ -1,13 +1,20 @@
package me.eternal.purrfectsnap.ui.manager.pages.location
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
@@ -16,6 +23,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -38,54 +46,105 @@ fun AddCoordinatesDialog(
alertDialogs.DefaultDialogCard {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
val fieldColors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedLabelColor = PurrfectPalette.textSecondary,
unfocusedLabelColor = PurrfectPalette.textSecondary,
cursorColor = PurrfectPalette.glowSecondary,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
Surface(
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp
) {
Text(translation["save_coordinates_dialog_title"], fontSize = 20.sp, fontWeight = FontWeight.Bold)
OutlinedTextField(
Column(
modifier = Modifier
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) }
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) }
)
OutlinedTextField(
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) }
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.cardOverlayColor.copy(alpha = 0.98f),
Color(0xFF1A143A).copy(alpha = 0.94f)
)
),
RoundedCornerShape(24.dp)
)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank()
Text(
text = translation["save_coordinates_dialog_title"],
fontSize = 20.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = savedName,
onValueChange = { savedName = it },
label = { Text(translation["saved_name_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
LaunchedEffect(Unit) {
delay(200)
focusRequester.requestFocus()
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLatitude,
onValueChange = { savedLatitude = it },
label = { Text(translation["latitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = savedLongitude,
onValueChange = { savedLongitude = it },
label = { Text(translation["longitude_dialog_hint"]) },
colors = fieldColors,
shape = RoundedCornerShape(18.dp),
singleLine = true
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp),
horizontalArrangement = Arrangement.End
) {
Text(translation["save_dialog_button"])
Button(
onClick = {
confirm(LocationCoordinates().apply {
this.name = savedName.text
this.latitude = savedLatitude.toDoubleOrNull() ?: 0.0
this.longitude = savedLongitude.toDoubleOrNull() ?: 0.0
})
},
enabled = savedName.text.isNotBlank() && savedLatitude.isNotBlank() && savedLongitude.isNotBlank(),
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
contentColor = Color.White,
disabledContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.16f),
disabledContentColor = Color.White.copy(alpha = 0.6f)
)
) {
Text(translation["save_dialog_button"])
}
}
}
}
}
}
}

View File

@@ -6,7 +6,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -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"])
}
}
}

View File

@@ -255,7 +255,7 @@ class ManageScriptReposSection : Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("script")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@@ -292,7 +293,7 @@ class ManageScriptReposSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

@@ -162,7 +162,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: "Unknown")))
context.shortToast(translation.format("error", "message" to (e.localizedMessage ?: context.translation["common.unknown"])))
}
}
}
@@ -206,8 +206,45 @@ fun ScriptCatalog(root: ScriptingRootSection) {
) {
item {
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.30f)
)
)
)
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
Text(
text = translation["loading"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
}
}
}
} else if (allScripts.isEmpty() && repositories.isNotEmpty()) {
Box(
@@ -311,7 +348,7 @@ fun ScriptCatalog(root: ScriptingRootSection) {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = translation.format("version", "version" to (entry.version ?: "N/A")),
text = translation.format("version", "version" to (entry.version ?: context.translation["common.not_available"])),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
fontWeight = FontWeight.Medium,
fontSize = 11.sp,

View File

@@ -30,7 +30,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.scripting.type.ModuleInfo
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
@@ -44,11 +46,11 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.storage.isScriptEnabled
import me.eternal.purrfectsnap.storage.setScriptEnabled
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.chooseFolder
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
@@ -57,15 +59,15 @@ import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
class ScriptingRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.scripting") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0)
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
internal val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
internal var selectedTab by mutableStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
internal suspend fun isScriptInstalledByUrl(scriptUrl: String): Boolean {
return try {
val installedScripts = context.scriptManager.getSyncedModules()
installedScripts.any { module ->
@@ -76,7 +78,7 @@ class ScriptingRootSection : Routes.Route() {
}
}
fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
internal fun downloadScript(scriptUrl: String, onComplete: () -> Unit) {
context.coroutineScope.launch {
if (isScriptInstalledByUrl(scriptUrl)) {
context.shortToast(translation["script_already_installed"])
@@ -97,7 +99,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ImportRemoteScript(
internal fun ImportRemoteScript(
dismiss: () -> Unit
) {
var url by remember { mutableStateOf("") }
@@ -140,7 +142,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
}
isLoading = false
}
@@ -172,7 +174,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ModuleActions(
internal fun ModuleActions(
script: ModuleInfo,
canUpdate: Boolean,
dismiss: () -> Unit
@@ -270,7 +272,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
fun ModuleItem(script: ModuleInfo) {
internal fun ModuleItem(script: ModuleInfo) {
var enabled by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(script)) {
context.database.isScriptEnabled(script.name)
}
@@ -430,8 +432,8 @@ class ScriptingRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {}
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
internal fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"]
Box(
modifier = Modifier
.fillMaxWidth()
@@ -439,13 +441,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(68.dp),
modifier = Modifier.size(78.dp),
shape = CircleShape,
color = Color.White.copy(alpha = 0.1f),
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
tonalElevation = 0.dp,
shadowElevation = 10.dp,
shadowElevation = 14.dp,
border = BorderStroke(
1.dp,
1.5.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,17 +458,18 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(6.dp)
.size(66.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
)
)
)
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
@@ -474,7 +477,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
tint = Color.White,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(34.dp)
)
}
}
@@ -482,7 +485,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
fun ScriptSettings(script: ModuleInfo) {
internal fun ScriptSettings(script: ModuleInfo) {
val settingsInterface = remember {
val module =
context.scriptManager.runtime.getModuleByName(script.name) ?: return@remember null
@@ -499,84 +502,23 @@ class ScriptingRootSection : Routes.Route() {
}
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) { context.scriptManager.getScriptsFolder() }
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
if (showImportDialog) {
ImportRemoteScript { showImportDialog = false }
}
if (showToast) {
LaunchedEffect(showToast) {
context.shortToast(translation["select_scripts_folder_toast"])
showToast = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
}
},
onImport = {
if (scriptingFolder == null) showToast = true else showImportDialog = true
},
onOpenFolder = {
if (scriptingFolder == null) {
showToast = true
} else {
scriptingFolder?.let {
context.androidContext.openLink(
it.uri.toString(),
context.translation["toast_open_link_failed"]
)
}
}
},
onManageRepos = { routes.manageScriptRepos.navigate() },
onDocs = {
context.androidContext.openLink(
"https://github.com/SnapEnhance/scripting-docs",
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
)
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@ScriptingRootSection.ScriptingScreen(nav)
}
}
}
@Composable
private fun InstalledTabContent(
internal fun InstalledTabContent(
scriptingFolder: DocumentFile?
) {
val scriptModules by rememberAsyncMutableState(
@@ -627,7 +569,7 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
Surface(
@@ -743,7 +685,7 @@ class ScriptingRootSection : Routes.Route() {
title = context.translation["manager.dialogs.scripting_warning.title"],
text = context.translation["manager.dialogs.scripting_warning.content"],
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"] ?: "OK",
confirmButtonText = translation["button.ok"],
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
@@ -799,7 +741,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun CatalogTabContent(
internal fun CatalogTabContent(
scriptingFolder: DocumentFile?
) {
val coroutineScope = rememberCoroutineScope()
@@ -832,7 +774,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingHeader(
internal fun ScriptingHeader(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit,
@@ -875,7 +817,7 @@ class ScriptingRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.scripts"] ?: "Scripts",
text = translation["manager.routes.scripts"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -910,7 +852,7 @@ class ScriptingRootSection : Routes.Route() {
}
@Composable
private fun ScriptingTabSwitcher(
internal fun ScriptingTabSwitcher(
titles: List<String>,
selectedTab: Int,
onTabSelected: (Int) -> Unit
@@ -957,3 +899,7 @@ class ScriptingRootSection : Routes.Route() {
override val topBarActions: @Composable() (RowScope.() -> Unit) = {}
}

View File

@@ -24,11 +24,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.getFriends
import me.eternal.purrfectsnap.storage.getGroups
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
@@ -219,35 +221,68 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
context.androidContext.sendBroadcast(it)
}.onFailure {
context.log.error("Failed to send broadcast", it)
hasFetchError = true
fun applySnapshot(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
coroutineScope.launch(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
val sortedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
// Priority sort for whitelisted groups
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.conversationId)
val bSelected = whitelistedIds.contains(b.conversationId)
if (aSelected != bSelected) if (aSelected) -1 else 1
else a.name.compareTo(b.name, ignoreCase = true)
}
}
}
withContext(Dispatchers.Main) {
cachedFriends = sortedFriends
cachedGroups = sortedGroups
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
}
}
// Initial database load
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
context.database.getGroups()
)
}
// Real-time synchronization flow
context.requestSocialSnapshotRefresh()
coroutineScope.launch {
context.database.messagingDataFlow.collect { (friends, groups) ->
applySnapshot(friends, groups)
}
}
timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) {
delay(20000)
hasFetchError = true
delay(25000)
if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) {
hasFetchError = true
}
}
}
}
DisposableEffect(Unit) {
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
}
}
me.eternal.purrfectsnap.ui.util.Dialog(
onDismissRequest = {
@@ -305,6 +340,7 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
@@ -315,6 +351,16 @@ class AddFriendDialog(
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
val selectedGroupCount by remember(filteredGroups) {
derivedStateOf {
filteredGroups.count { group ->
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
}
}
}
val hasGroupsSelected = selectedGroupCount > 0
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
DialogHeader(searchKeyword)
LazyColumn(
@@ -324,14 +370,54 @@ class AddFriendDialog(
) {
item {
if (filteredGroups.isNotEmpty()) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 8.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = true
actionHandler.onGroupState(group, true)
}
}
},
enabled = !allGroupsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = false
actionHandler.onGroupState(group, false)
}
}
},
enabled = hasGroupsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}
}
}
}
@@ -375,13 +461,8 @@ class AddFriendDialog(
enabled = !allFriendsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"]
?: "Select All",
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
@@ -397,11 +478,7 @@ class AddFriendDialog(
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}

View File

@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import java.io.File
import java.text.DateFormat
import java.util.Date
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
override val title: @Composable () -> Unit = {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val text = remember(navBackStackEntry) {
navBackStackEntry?.arguments?.getString("id")?.let {
context.database.getFriendInfo(it)?.displayName
}
val userId = navBackStackEntry?.arguments?.getString("id")
val displayName by rememberAsyncMutableState(defaultValue = null) {
userId?.let { context.database.getFriendInfo(it)?.displayName }
}
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.database.getFriendInfo(userId) }
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
context.database.getFriendInfo(userId)
}
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -91,7 +93,7 @@ class ManageScope: Routes.Route() {
title = translation.format("delete_scope_confirm_dialog_title", "scope" to context.translation["scopes.${scope.key}"]),
text = "",
icon = Icons.Rounded.DeleteForever,
confirmButtonText = translation["delete_button"] ?: "Delete",
confirmButtonText = translation["delete_button"],
dismissButtonText = context.translation["button.cancel"],
onDismiss = { deleteConfirmDialog = false },
onConfirm = {
@@ -109,7 +111,7 @@ class ManageScope: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = titleText ?: "Manage",
title = titleText ?: translation["manage_scope_title"],
onBack = { routes.navController.popBackStack() },
actions = {
IconButton(onClick = { deleteConfirmDialog = true }) {
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
val rules = rememberAsyncMutableStateList(listOf()) {
context.database.getRules(id)
context.database.getRules(id).normalizeStealthRules().toList()
}
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
val previousRules = rules.toSet()
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
val changedRules = (previousRules + updatedRules).filter { rule ->
(rule in previousRules) != (rule in updatedRules)
}
rules.clear()
rules.addAll(updatedRules)
changedRules.forEach { changedRule ->
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
}
}
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember(rules.size) {
mutableStateOf(rules.any { it.key == ruleType.key })
}
val ruleState = context.config.root.rules.getRuleState(ruleType)
val ruleEnabled = rules.any { it.key == ruleType.key }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.database.setRule(id, ruleType.key, it)
ruleEnabled = it
updateRules(ruleType, it)
},
colors = purrfectSwitchColors()
)

View File

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

View File

@@ -0,0 +1,30 @@
package me.eternal.purrfectsnap.ui.manager.pages.social
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.storage.getFriends
internal fun RemoteSideContext.sortSocialFriends(
friends: List<MessagingFriendInfo>,
pinnedIds: List<String>? = null
): List<MessagingFriendInfo> {
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
return friends.sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.userId)
val bSelected = whitelistedIds.contains(b.userId)
if (aSelected != bSelected) {
return@sortedWith if (aSelected) -1 else 1
}
if (sortByStreakLength) {
val aStreak = a.streaks?.length ?: 0
val bStreak = b.streaks?.length ?: 0
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
}
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
}
}

View File

@@ -35,7 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
@@ -44,22 +44,49 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
class SocialRootSection : Routes.Route() {
private var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
private var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
private fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
@Composable
fun SocialDataController() {
LaunchedEffect(Unit) {
// Initial data fetch from the database
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
// Real-time synchronization from the bridge
context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
}
}
}
internal fun requestLatestSnapshot() {
context.requestSocialSnapshotRefresh()
}
@Composable
private fun ScopeList(
internal fun ScopeList(
scope: SocialScope,
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
@@ -76,7 +103,6 @@ class SocialRootSection : Routes.Route() {
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
//check if scope list is empty
val listSize = list.size
if (listSize == 0) {
@@ -119,11 +145,6 @@ class SocialRootSection : Routes.Route() {
addFriendDialog?.Content {
addFriendDialog = null
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
FloatingActionButton(
@@ -153,8 +174,7 @@ class SocialRootSection : Routes.Route() {
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
),
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
)
)
},
modifier = Modifier
@@ -185,137 +205,23 @@ class SocialRootSection : Routes.Route() {
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
updateScopeLists()
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList
} else {
friendList.filter {
it.mutableUsername.contains(normalizedQuery, ignoreCase = true) ||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
val filteredGroups = remember(groupList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
groupList
} else {
groupList.filter { it.name.contains(normalizedQuery, ignoreCase = true) }
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(initialValue = context.config.root.global.uiSettings.managerTheme.get()) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
SocialHeader(
titles = titles,
pagerState = pagerState,
onTabSelected = { index ->
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
friendCount = friendList.size,
groupCount = groupList.size,
searchActive = searchActive,
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
)
if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = searchShape,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, searchBorder),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, searchShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = searchHint,
tint = PurrfectPalette.textSecondary
)
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontSize = 15.sp
),
cursorBrush = SolidColor(PurrfectPalette.glowSecondary),
modifier = Modifier.weight(1f)
) { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = searchHint,
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
innerTextField()
}
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["clear_search_button_description"],
tint = Color.White
)
}
}
}
}
}
Spacer(Modifier.height(12.dp))
HorizontalPager(
modifier = Modifier
.fillMaxSize(),
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@SocialRootSection.SocialScreen(nav)
}
}
}
@Composable
private fun SocialCard(
internal fun SocialCard(
scope: SocialScope,
friend: MessagingFriendInfo?,
group: MessagingGroupInfo?,
@@ -478,7 +384,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialHeader(
internal fun SocialHeader(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit,
@@ -520,7 +426,7 @@ class SocialRootSection : Routes.Route() {
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.social"] ?: "Social",
text = translation["manager.routes.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
@@ -552,7 +458,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun SocialTabSwitcher(
internal fun SocialTabSwitcher(
titles: List<String>,
pagerState: androidx.compose.foundation.pager.PagerState,
onTabSelected: (Int) -> Unit
@@ -598,7 +504,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun EmptyState(scope: SocialScope) {
internal fun EmptyState(scope: SocialScope) {
val title = when (scope) {
SocialScope.FRIEND -> translation.getOrNull("friends_empty_title") ?: translation["empty_hint"]
SocialScope.GROUP -> translation.getOrNull("groups_empty_title") ?: translation["empty_hint"]
@@ -625,13 +531,13 @@ class SocialRootSection : Routes.Route() {
modifier = Modifier.size(26.dp)
)
Text(
text = title,
text = title ?: "",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp
)
Text(
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
text = translation["social_empty_hint"] ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -640,7 +546,7 @@ class SocialRootSection : Routes.Route() {
}
@Composable
private fun StatPill(label: String, value: Int) {
internal fun StatPill(label: String, value: Int) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.08f),
@@ -669,3 +575,7 @@ class SocialRootSection : Routes.Route() {
}
}
}

View File

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

View File

@@ -0,0 +1,56 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.runtime.*
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.PropertyPair
import me.eternal.purrfectsnap.common.config.toPropertyPair
@Composable
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
when (currentDestination?.route) {
FEATURE_CONTAINER_ROUTE -> {
val containerName = navBackStackEntry?.arguments?.getString("name")!!
val propertyPair = allContainers[containerName]!!
Container(
configContainer = propertyPair.value.get() as ConfigContainer,
stateKey = "${routeInfo.id}:container:$containerName",
sectionTitle = context.translation[propertyPair.key.propertyName()],
sectionSubtitle = context.translation[propertyPair.key.propertyDescription()],
onBack = { routes.navController.popBackStack() }
)
}
SEARCH_FEATURE_ROUTE -> {
val keyword = navBackStackEntry?.arguments?.getString("keyword").orEmpty()
val properties = allProperties.filter {
isSearchVisibleProperty(it.key) && (
it.key.name.contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { (it.key to it.value).toPropertyPair() }
PropertiesView(
properties = properties,
stateKey = "${routeInfo.id}:search:$keyword",
isSearchResults = true,
searchKeyword = keyword,
enableGlobalSearch = true,
onBack = { navigateToMainRoot() }
)
}
else -> {
Container(
configContainer = context.config.root,
stateKey = "${routeInfo.id}:container:root"
)
}
}
}

View File

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

View File

@@ -0,0 +1,256 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() {
isRefreshing = true
coroutineScope.launch(Dispatchers.IO) {
val readerResult = runCatching {
context.log.newReader { /* items are batch-added from reader logic below */ }
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
withContext(Dispatchers.Main) { isRefreshing = false }
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
val filteredLogs = (0 until reader.lineCount).mapNotNull { index ->
reader.getLogLine(index)?.takeUnless(::shouldHideLog)
}
withContext(Dispatchers.Main) {
visibleLogs.clear()
visibleLogs.addAll(filteredLogs)
if (visibleLogs.isNotEmpty()) {
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
}
isRefreshing = false
}
}
}
}
@Composable
fun LogFilterDialog() {
Dialog(onDismissRequest = { showFilterDialog = false }) {
PurrfectOverlayTheme {
PurrfectGlassCard(
title = translation["filter_logs_title"] ?: "Log Filters",
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories[category] = !(enabledCategories[category] ?: true)
refreshLogs()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.3f),
checkmarkColor = Color.White
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
)
}
}
}
}
Button(
onClick = { showFilterDialog = false },
modifier = Modifier.fillMaxWidth().height(54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
}
}
if (showFilterDialog) {
LogFilterDialog()
}
LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) {
refreshLogs()
}
}
LaunchedEffect(Unit) {
refreshLogs()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var showDropDown by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 12.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = { showFilterDialog = true }) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
}
IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
clearLogsAndReload()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"] ?: "Clear", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
exportLogs()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"] ?: "Export", color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
)
}
}

View File

@@ -0,0 +1,92 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavBackStackEntry
import androidx.documentfile.provider.DocumentFile
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.common.util.ktx.openLink
import kotlinx.coroutines.launch
@Composable
fun ScriptingRootSection.AphelionScriptingScreen(nav: NavBackStackEntry) {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
updateDispatcher = reloadDispatcher
) { context.scriptManager.getScriptsFolder() }
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
}
}
if (showImportDialog) {
ImportRemoteScript { showImportDialog = false }
}
if (showToast) {
LaunchedEffect(showToast) {
context.shortToast(translation["select_scripts_folder_toast"])
showToast = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
}
},
onImport = {
if (scriptingFolder == null) showToast = true else showImportDialog = true
},
onOpenFolder = {
if (scriptingFolder == null) {
showToast = true
} else {
scriptingFolder?.let {
context.androidContext.openLink(
it.uri.toString(),
context.translation["toast_open_link_failed"]
)
}
}
},
onManageRepos = { routes.manageScriptRepos.navigate() },
onDocs = {
context.androidContext.openLink(
"https://github.com/SnapEnhance/scripting-docs",
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
)
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.runtime.Composable
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.ThemeContract
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeAbout
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeSettings
import me.eternal.purrfectsnap.ui.manager.pages.scripting.ScriptingRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.tracker.FriendTrackerManagerRoot
object AphelionTheme : ThemeContract {
@Composable
override fun HomeRootSection.HomeScreen(nav: NavBackStackEntry) {
AphelionHomeScreen(nav)
}
@Composable
override fun HomeSettings.SettingsScreen(nav: NavBackStackEntry) {
AphelionSettingsScreen(nav)
}
@Composable
override fun HomeAbout.AboutScreen(nav: NavBackStackEntry) {
AphelionAboutScreen(nav)
}
@Composable
override fun HomeLogs.LogsScreen(nav: NavBackStackEntry) {
AphelionLogsScreen(nav)
}
@Composable
override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) {
AphelionSocialScreen(nav)
}
@Composable
override fun TasksRootSection.TasksScreen(nav: NavBackStackEntry) {
AphelionTasksScreen(nav)
}
@Composable
override fun FeaturesRootSection.FeaturesScreen(nav: NavBackStackEntry) {
AphelionFeaturesScreen(nav)
}
@Composable
override fun ScriptingRootSection.ScriptingScreen(nav: NavBackStackEntry) {
AphelionScriptingScreen(nav)
}
@Composable
override fun FriendTrackerManagerRoot.FriendTrackerScreen(nav: NavBackStackEntry) {
TrackerScreenContent(nav)
}
}

View File

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

View File

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

View File

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

View File

@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Store
import androidx.compose.ui.window.Dialog
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
@@ -51,6 +46,7 @@ import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
@@ -67,18 +63,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
override val translation by lazy { context.translation.getCategory("manager.friend_tracker") }
private val titles by lazy {
internal val titles by lazy {
listOf(
translation["rules_tab"],
translation["logs_tab"]
)
}
private var currentPage by mutableIntStateOf(0)
private lateinit var logDeleteAction : () -> Unit
private lateinit var exportAction : () -> Unit
internal var currentPage by mutableIntStateOf(0)
internal lateinit var logDeleteAction : () -> Unit
internal lateinit var exportAction : () -> Unit
@Composable
private fun TrackerIconButton(
internal fun TrackerIconButton(
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
@@ -114,7 +110,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun TrackerActionButton(
internal fun TrackerActionButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
@@ -156,7 +152,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun TrackerPillButton(
internal fun TrackerPillButton(
label: String,
icon: ImageVector,
onClick: () -> Unit,
@@ -304,7 +300,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
internal lateinit var activityLauncherHelper: ActivityLauncherHelper
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -347,7 +343,7 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun ConfigRulesTab() {
internal fun ConfigRulesTab() {
val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc()
@@ -568,9 +564,9 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
@OptIn(ExperimentalFoundationApi::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
@Composable
internal fun TrackerScreenContent(nav: NavBackStackEntry) {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage
@@ -625,47 +621,47 @@ class FriendTrackerManagerRoot : Routes.Route() {
)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier
@@ -796,6 +792,22 @@ class FriendTrackerManagerRoot : Routes.Route() {
)
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = { nav ->
val themeId by produceState(
initialValue = context.config.root.global.uiSettings.managerTheme.get()
) {
while (true) {
delay(300)
value = context.config.root.global.uiSettings.managerTheme.get()
}
}
key(themeId) {
with(ManagerTheme.fromId(themeId).theme) {
this@FriendTrackerManagerRoot.FriendTrackerScreen(nav)
}
}
}
}
@Composable

View File

@@ -245,7 +245,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
}.onFailure {
context.log.error("Failed to add repository", it)
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: "Unknown")))
context.shortToast(translation.format("add_repo_failed_toast", "message" to (it.message ?: context.translation["common.unknown"])))
}
loading = false
}
@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("friend_tracker")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
@@ -282,7 +283,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: (translation["title"] ?: "Repositories"),
title = routeInfo.translatedKey?.value ?: translation["title"],
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)

View File

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

View File

@@ -0,0 +1,60 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import me.eternal.purrfectsnap.RemoteSideContext
/**
* Specialized haptic engine for the Aphelion "Liquid Glass" experience.
* Provides more nuanced feedback than standard Compose haptics.
*/
object AphelionHaptics {
/**
* Triggers a subtle, sharp "tick" intended for the start of a theme reveal.
*/
fun themeRevealTick(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = androidContext.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
} else {
// Fallback for older APIs
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
}.onFailure { it.printStackTrace() }
}
/**
* Triggers a "soft" impact feel, good for glass interactions.
*/
fun softImpact(remoteSideContext: RemoteSideContext, haptic: HapticFeedback) {
runCatching {
if (!shouldPerformHaptics(remoteSideContext)) return
val androidContext = remoteSideContext.androidContext
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val vibrator = androidContext.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}.onFailure { it.printStackTrace() }
}
private fun shouldPerformHaptics(remoteSideContext: RemoteSideContext): Boolean {
return remoteSideContext.config.root.global.uiSettings.hapticFeedback.get()
}
}

View File

@@ -0,0 +1,111 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import android.graphics.BitmapShader
import android.graphics.Shader
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlin.math.sqrt
import androidx.compose.ui.platform.LocalHapticFeedback
import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
onComplete: () -> Unit
) {
// Safety check: if bitmap was recycled or is null, skip.
val bitmap = request.oldThemeBitmap ?: run {
LaunchedEffect(request.id) { onComplete() }
return
}
if (bitmap.isRecycled) {
LaunchedEffect(request.id) { onComplete() }
return
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
// Ensure the reveal is cleared even if navigation happens mid-animation
DisposableEffect(request.id) {
onDispose { onComplete() }
}
val maxRadius = remember(configuration) {
with(density) {
val w = configuration.screenWidthDp.dp.toPx()
val h = configuration.screenHeightDp.dp.toPx()
sqrt(w * w + h * h)
}
}
val animatedRadius = remember(request.id) { Animatable(0f) }
LaunchedEffect(request.id) {
AphelionHaptics.themeRevealTick(context, hapticFeedback)
animatedRadius.animateTo(
targetValue = maxRadius + WAVE_BAND_WIDTH_PX,
animationSpec = tween(durationMillis = REVEAL_DURATION_MS, easing = AphelionEasing)
)
onComplete()
}
val progress = (animatedRadius.value / (maxRadius + WAVE_BAND_WIDTH_PX)).coerceIn(0f, 1f)
val infiniteTransition = rememberInfiniteTransition(label = "wave_time")
val timeValue by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 10f,
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 5_000, easing = LinearEasing)),
label = "wave_time_value"
)
// --- AGSL SHADER LOGIC (Android 13+) ---
val runtimeShader = remember(bitmap) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
}
val shaderPaint = remember(runtimeShader) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader
}
}
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = animatedRadius.value
val center = request.originCenter
drawIntoCanvas { canvas ->
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
}
}
}

View File

@@ -0,0 +1,61 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
/**
* Carries all data needed to execute one theme reveal transition.
*/
data class ThemeRevealRequest(
val newThemeId: String,
val originCenter: Offset,
val oldThemeBitmap: android.graphics.Bitmap?,
val id: Long = System.currentTimeMillis()
)
/**
* Observable state that lives on the [Navigation] instance.
* Optimized for stability during rapid toggle events.
*/
class ThemeRevealState {
var pendingReveal by mutableStateOf<ThemeRevealRequest?>(null)
private set
/**
* Requests a new theme reveal animation.
* Always starts a new reveal immediately, even if one is already in progress.
*/
fun requestReveal(
newThemeId: String,
originCenter: Offset,
bitmap: android.graphics.Bitmap?
) {
// Clean up the old one first to prevent memory leaks and "dead periods"
val oldBitmap = pendingReveal?.oldThemeBitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
// Immediately update with the new request ID to force a fresh animation
pendingReveal = ThemeRevealRequest(
newThemeId = newThemeId,
originCenter = originCenter,
oldThemeBitmap = bitmap,
id = System.currentTimeMillis() // Unique ID ensures fresh start
)
}
/** Called by the overlay composable once the animation has fully completed. */
fun clearReveal() {
val oldBitmap = pendingReveal?.oldThemeBitmap
pendingReveal = null
// Manual memory management for the heavy screenshot bitmap
if (oldBitmap?.isRecycled == false) {
oldBitmap.recycle()
}
}
}

View File

@@ -0,0 +1,86 @@
package me.eternal.purrfectsnap.ui.manager.theme.aphelion
/**
* Finalized "Perfect Optic" AGSL Shader.
* Features balanced thickness, high-impact refraction, and explosive kinetic energy.
*/
object WaveEdgeShader {
const val AGSL = """
uniform shader content;
uniform float revealRadius;
uniform float2 revealCenter;
uniform float bandWidth;
uniform float time;
uniform float uProgress;
float hash(float2 p) {
return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453123);
}
float valueNoise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + float2(0.0, 0.0)), hash(i + float2(1.0, 0.0)), u.x),
mix(hash(i + float2(0.0, 1.0)), hash(i + float2(1.0, 1.0)), u.x),
u.y
);
}
half4 main(float2 pos) {
float d = distance(pos, revealCenter);
float energy = (1.0 - uProgress);
// Slower, more majestic large noise
float noiseLarge = valueNoise(pos * 0.003 + time * 0.08) * 70.0;
float totalNoise = noiseLarge * energy;
float dist = d - (revealRadius + totalNoise);
if (dist > 0.0) {
return content.eval(pos);
}
// Reverting to balanced thickness (Starts at 50% width, grows to 100%)
float dynamicBand = bandWidth * (0.5 + 0.5 * uProgress);
float effectStart = revealRadius - dynamicBand;
if (dist < -dynamicBand) {
return half4(0.0);
}
float bandProgress = clamp((dist + dynamicBand) / dynamicBand, 0.0, 1.0);
// Asymmetric crest: Sharp start, long slow tail
float waveShape = pow(bandProgress, 2.0);
float2 dir = normalize(pos - revealCenter + 0.001);
// Refraction: Impactful but clear
float refractionAmt = waveShape * (60.0 * energy + 25.0) + (totalNoise * 0.15);
float2 refractedPos = pos + dir * refractionAmt;
// Chromatic Aberration: Deep prism split
float aberration = waveShape * (35.0 * energy + 10.0);
half r = content.eval(refractedPos + dir * aberration).r;
half g = content.eval(refractedPos).g;
half b = content.eval(refractedPos - dir * aberration).b;
// Sharp highlight at the front edge
float highlight = pow(waveShape, 1.1) * (0.5 * energy + 0.15);
// Leading edge softening (minor)
float leadingEdgeFade = smoothstep(revealRadius, revealRadius - 10.0, d - totalNoise);
float alpha = smoothstep(0.0, 0.25, bandProgress) * leadingEdgeFade;
return half4(
r + half(highlight),
g + half(highlight),
b + half(highlight),
half(alpha)
);
}
"""
}

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
val setupPrefs = setupContext.sharedPreferences
val setupRoutes = Routes(setupContext).apply {
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
}
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
val requiredScreens = mutableListOf<SetupScreen>().apply {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(IntroShowcaseScreen().apply { route = "introShowcase" })
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen(
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
val bottomPadding = 118.dp + navBarPadding
Column(
modifier = Modifier
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
.navigationBarsPadding()
.padding(bottom = 32.dp)
)
setupAiPrompt?.let { prompt ->
ManagerAssistantDialog(
context = setupContext,
routes = setupRoutes,
initialUserMessage = prompt,
onDismiss = { setupAiPrompt = null }
)
}
}
}
}
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language
)
is IntroShowcaseScreen -> SetupStepMeta(
route = route,
title = "Welcome",
subtitle = "Preview what PurrfectSnap can do",
icon = Icons.Filled.AutoAwesome
)
is InstallModeScreen -> SetupStepMeta(
route = route,
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
}
@Composable
private fun SetupTopBar() {
private fun SetupTopBar(onAskAi: () -> Unit) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "PurrfectSnap",
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
Surface(
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.08f),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable(onClick = onAskAi)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
Text(
text = "Ask AI",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
class IntroShowcaseScreen : SetupScreen() {
private val slides = listOf(
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
)
@Composable
override fun Content() {
LaunchedEffect(Unit) { allowNext(true) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(5000)
currentIndex = (currentIndex + 1) % slides.size
}
}
SetupCard {
StepTitle(
title = "Welcome to PurrfectSnap",
subtitle = "A quick look before setup begins",
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Image(
painter = painterResource(slides[index].first),
contentDescription = slides[index].second,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
)
}
Text(
text = slides[index].second,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
slides.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(if (index == currentIndex) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
)
}
}
}
}
}

View File

@@ -11,21 +11,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -34,15 +27,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -55,16 +44,7 @@ class MappingsScreen : SetupScreen() {
val translation = context.translation
var infoText by remember { mutableStateOf(null as String?) }
var isGenerating by remember { mutableStateOf(false) }
var showCompletionNotice by remember { mutableStateOf(false) }
var completionCountdown by remember { mutableIntStateOf(10) }
fun finishMappings() {
if (isFirstRunFlow) {
showCompletionNotice = true
} else {
goNext()
}
}
fun finishMappings() = goNext()
if (infoText != null) {
fun dismiss() {
@@ -87,103 +67,6 @@ class MappingsScreen : SetupScreen() {
}
}
LaunchedEffect(showCompletionNotice) {
if (showCompletionNotice) {
completionCountdown = 10
while (completionCountdown > 0) {
delay(1000)
completionCountdown--
}
}
}
if (showCompletionNotice) {
val confirmLabel = if (completionCountdown > 0) {
translation.format(
"setup.mappings.confirm_understand_timeout",
"seconds" to completionCountdown.toString()
)
} else {
translation["setup.mappings.confirm_understand"]
}
AestheticDialog(
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
title = translation["setup.mappings.notice_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
confirmEnabled = completionCountdown == 0,
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.mappings.notice_intro"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_1"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_2"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_3"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_title"],
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold,
lineHeight = 18.sp
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_body"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isGenerating) return@launch

View File

@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val assistantRoutes = remember {
Routes(context).apply {
}
}
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
ManagerAssistantDialog(
context = context,
routes = assistantRoutes,
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
showImprovementLogging = false,
onDismiss = { showIssuesDialog = false }
)
}

View File

@@ -52,6 +52,16 @@ class SaveFolderScreen : SetupScreen() {
var currentFolder by remember {
mutableStateOf(context.config.root.downloader.saveFolder.get().orEmpty())
}
val readablePath = remember(currentFolder) {
if (currentFolder.isBlank()) null
else runCatching {
val uri = android.net.Uri.parse(currentFolder)
val path = uri.path ?: return@runCatching currentFolder
if (path.contains("tree/")) {
path.substringAfter("tree/").replace("primary:", "Internal Storage/").replace(":", "/")
} else currentFolder
}.getOrElse { currentFolder }
}
var showNoPickerDialog by remember { mutableStateOf(false) }
SetupCard {
StepTitle(
@@ -104,7 +114,7 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary
)
Text(
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder,
text = readablePath ?: context.translation["setup.save_folder.system_default_label"],
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
@@ -10,6 +11,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -26,9 +28,12 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -40,7 +45,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.config.ConfigFlag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog as StandardDialog
import androidx.core.net.toUri
import com.github.skydoves.colorpicker.compose.*
import com.google.gson.JsonParser
@@ -57,19 +61,171 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
private val translation: LocaleWrapper,
){
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName ?: "",
messageListJson = currentValue,
onSave = { newValue: String ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutoOpenScheduleDialog(
property: PropertyPair<String>,
onDismiss: () -> Unit
) {
val windowParts = (property.value.get() as String).split("-")
val startTime = windowParts.getOrNull(0)?.split(":") ?: listOf("23", "00")
val endTime = windowParts.getOrNull(1)?.split(":") ?: listOf("07", "00")
var isEditingEnd by remember { mutableStateOf(false) }
val startState = rememberTimePickerState(
initialHour = startTime.getOrNull(0)?.toIntOrNull() ?: 23,
initialMinute = startTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
val endState = rememberTimePickerState(
initialHour = endTime.getOrNull(0)?.toIntOrNull() ?: 7,
initialMinute = endTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
DefaultDialogCard {
Column(
modifier = Modifier.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = translation["auto_open_snaps.auto_open_schedule.title"] ?: "Auto Open Scheduler",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val activeColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
val inactiveColor = Color.Transparent
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (!isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = false }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.start"] ?: "Start"}: ${String.format("%02d:%02d", startState.hour, startState.minute)}",
color = if (!isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (!isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = true }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.end"] ?: "End"}: ${String.format("%02d:%02d", endState.hour, endState.minute)}",
color = if (isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
}
TimePicker(
state = if (isEditingEnd) endState else startState,
colors = TimePickerDefaults.colors(
clockDialColor = Color.White.copy(alpha = 0.05f),
clockDialSelectedContentColor = Color.White,
clockDialUnselectedContentColor = Color.White.copy(alpha = 0.7f),
selectorColor = PurrfectPalette.glowPrimary,
periodSelectorBorderColor = PurrfectPalette.glowPrimary,
periodSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
periodSelectorUnselectedContainerColor = Color.Transparent,
periodSelectorSelectedContentColor = Color.White,
periodSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f),
timeSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
timeSelectorUnselectedContainerColor = Color.White.copy(alpha = 0.05f),
timeSelectorSelectedContentColor = Color.White,
timeSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(text = translation["button.negative"], color = Color.White)
}
Button(
onClick = {
val startStr = String.format("%02d:%02d", startState.hour, startState.minute)
val endStr = String.format("%02d:%02d", endState.hour, endState.minute)
property.value.setAny("$startStr-$endStr")
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
contentColor = Color.White
)
) {
Text(text = translation["button.positive"])
}
}
}
}
}
@Composable
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
val scrollState = rememberScrollState()
@@ -275,9 +431,10 @@ class AlertDialogs(
DefaultDialogCard {
var fieldValue by remember {
mutableStateOf(property.value.get().toString().let {
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
TextFieldValue(
text = it,
selection = TextRange(it.length)
text = t,
selection = TextRange(t.length)
)
})
}
@@ -291,10 +448,21 @@ class AlertDialogs(
}
.focusRequester(focusRequester),
value = fieldValue,
onValueChange = { fieldValue = it },
keyboardOptions = when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
onValueChange = { newVal ->
fieldValue = if (property.key.params.digitsOnlyInput) {
val filtered = newVal.text.filter { ch -> ch.isDigit() }
if (newVal.text != filtered) {
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
}
newVal.copy(text = filtered)
} else {
newVal
}
},
keyboardOptions = when {
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,
@@ -360,6 +528,68 @@ class AlertDialogs(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
val context = LocalContext.current
val zoneId = remember { ZoneId.systemDefault() }
val initialSelectedDateMillis = remember(property.value.get()) {
runCatching {
LocalDate
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
}.getOrNull()
}
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
DefaultDialogCard {
DatePicker(
state = datePickerState,
showModeToggle = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
) {
Button(
onClick = { dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val selectedDate = datePickerState.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
}
if (selectedDate == null) {
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
return@Button
}
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
dismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Text(text = translation["button.ok"])
}
}
}
}
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
@@ -497,7 +727,7 @@ class AlertDialogs(
currentColor = null
}
},
label = { Text(text = "Hex Color") },
label = { Text(text = translation["dialogs.hex_color_label"]) },
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
@@ -592,6 +822,8 @@ class AlertDialogs(
property: PropertyPair<*>,
marker: MutableState<Marker?> = remember { mutableStateOf(null) },
mapView: MutableState<MapView?> = remember { mutableStateOf(null) },
locationSearchProvider: String = "osm",
googleMapsApiKey: String = "",
saveCoordinates: (() -> Unit)? = null,
dismiss: () -> Unit = {}
) {
@@ -602,6 +834,13 @@ class AlertDialogs(
}
}
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
fun dismissKeyboard() {
keyboardController?.hide()
focusManager.clearFocus(force = true)
}
mapView.value = remember {
Configuration.getInstance().apply {
@@ -611,7 +850,20 @@ class AlertDialogs(
MapView(context).apply {
setMultiTouchControls(true)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
setTileSource(TileSourceFactory.MAPNIK)
val tileSource = if (locationSearchProvider == "google_maps") {
object : OnlineTileSourceBase(
"GoogleMaps",
0, 19, 256, ".png",
arrayOf("https://mt0.google.com/vt/lyrs=m", "https://mt1.google.com/vt/lyrs=m", "https://mt2.google.com/vt/lyrs=m", "https://mt3.google.com/vt/lyrs=m")
) {
override fun getTileURLString(pMapTileIndex: Long): String {
return baseUrl + "&x=" + MapTileIndex.getX(pMapTileIndex) + "&y=" + MapTileIndex.getY(pMapTileIndex) + "&z=" + MapTileIndex.getZoom(pMapTileIndex)
}
}
} else {
TileSourceFactory.MAPNIK
}
setTileSource(tileSource)
val startPoint = GeoPoint(coordinates.first, coordinates.second)
controller.setZoom(10.0)
@@ -625,12 +877,37 @@ class AlertDialogs(
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
dismissKeyboard()
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
mapView.invalidate()
return true
}
})
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
override fun longPressHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
}))
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
dismissKeyboard()
return false
}
override fun onZoom(event: ZoomEvent?): Boolean {
dismissKeyboard()
return false
}
})
overlays.add(marker.value)
}
}
@@ -685,29 +962,70 @@ class AlertDialogs(
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val resultsScrollState = rememberScrollState()
suspend fun search() {
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon().appendQueryParameter("q", locationName).appendQueryParameter("format", "jsonv2").build().toString())
.header("User-Agent", Constants.USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) {
return@use
}
BackHandler {
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
if (shouldDismissKeyboard) {
dismissKeyboard()
locationName = ""
addressResults = emptyList()
searchJob?.cancel()
searchJob = null
} else {
dismiss()
}
}
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
suspend fun search() {
if (locationSearchProvider == "google_maps") {
// Google Maps Search
okHttpClient.newCall(Request.Builder()
.url("https://maps.googleapis.com/maps/api/geocode/json".toUri().buildUpon()
.appendQueryParameter("address", locationName)
.appendQueryParameter("key", googleMapsApiKey)
.build().toString())
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val jsonResponse = JsonParser.parseString(response.body?.string() ?: "{}").asJsonObject
if (jsonResponse.has("results")) {
val results = jsonResponse.getAsJsonArray("results")
addressResults = results.take(5).map { jsonElement ->
val result = jsonElement.asJsonObject
val geometry = result.getAsJsonObject("geometry").getAsJsonObject("location")
Triple(
result.get("formatted_address").asString,
geometry.get("lat").asString,
geometry.get("lng").asString
)
}
}
}
}
} else {
// OSM Nominatim Search (Existing Logic)
okHttpClient.newCall(Request.Builder()
.url("https://nominatim.openstreetmap.org/search".toUri().buildUpon()
.appendQueryParameter("q", locationName)
.appendQueryParameter("format", "jsonv2")
.build().toString())
.header("User-Agent", Constants.OSM_USER_AGENT)
.build()
).await().use { response ->
if (!response.isSuccessful) return@use
runCatching {
val body = JsonParser.parseString(response.body?.string() ?: "[]").asJsonArray
addressResults = body.take(5).map { jsonElement ->
val jsonObject = jsonElement.asJsonObject
Triple(
jsonObject.get("display_name").asString,
jsonObject.get("lat").asString,
jsonObject.get("lon").asString
)
}
}
}
}
searchJob = null
}
@@ -771,7 +1089,7 @@ class AlertDialogs(
color = Color.White
)
Text(
text = "Search or tap on the map",
text = betterLocationTranslation["search_or_tap_map_hint"],
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
@@ -825,11 +1143,11 @@ class AlertDialogs(
search()
}
},
placeholder = { Text(text = "Search location...") },
placeholder = { Text(text = betterLocationTranslation["search_location_placeholder"]) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = "Search",
contentDescription = betterLocationTranslation["search_icon_description"],
tint = Color.White
)
},
@@ -901,6 +1219,7 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.clickable {
dismissKeyboard()
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
@@ -949,7 +1268,7 @@ class AlertDialogs(
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Searching...",
text = betterLocationTranslation["searching_label"],
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
@@ -1048,7 +1367,7 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(
StandardDialog(
onDismissRequest = {
customCoordinatesDialog = false
},
@@ -1097,7 +1416,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lat.value,
onValueChange = { lat.value = it },
label = { Text(text = "Latitude") },
label = { Text(text = translation["latitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.MyLocation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
@@ -1117,7 +1436,7 @@ class AlertDialogs(
.padding(horizontal = 6.dp),
value = lon.value,
onValueChange = { lon.value = it },
label = { Text(text = "Longitude") },
label = { Text(text = translation["longitude_dialog_hint"]) },
leadingIcon = { Icon(Icons.Filled.Navigation, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(14.dp),
@@ -1161,23 +1480,7 @@ class AlertDialogs(
}
}
}
}
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName,
messageListJson = currentValue,
onSave = { newValue ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
}
@Composable
fun MessageListManagerDialog(
@@ -1214,7 +1517,6 @@ class AlertDialogs(
textAlign = TextAlign.Center
)
// Message list
Card(
modifier = Modifier
.fillMaxWidth()
@@ -1230,7 +1532,7 @@ class AlertDialogs(
contentAlignment = Alignment.Center
) {
Text(
text = translation["auto_reply_messages.dialog.no_messages"],
text = translation["bulk_messaging_action.no_messages_found"] ?: "No messages",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -1272,24 +1574,14 @@ class AlertDialogs(
showAddDialog = true
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
tint = MaterialTheme.colorScheme.primary
)
Icon(Icons.Default.Edit, contentDescription = translation["common.edit"] ?: "Edit", tint = MaterialTheme.colorScheme.primary)
}
IconButton(
onClick = {
messageList = messageList.toMutableList().apply {
removeAt(index)
}
messageList = messageList.toMutableList().apply { removeAt(index) }
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
Icon(Icons.Default.Delete, contentDescription = translation["common.delete"] ?: "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
@@ -1299,7 +1591,6 @@ class AlertDialogs(
}
}
// Add button
Button(
onClick = {
editingIndex = -1
@@ -1309,20 +1600,13 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["auto_reply_messages.dialog.add_message"])
Text(text = translation["common.add"] ?: "Add Message")
}
// Dialog buttons
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1331,17 +1615,14 @@ class AlertDialogs(
) {
Button(
onClick = { onDismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val gson = com.google.gson.Gson()
val jsonString = gson.toJson(messageList)
onSave(jsonString)
onSave(gson.toJson(messageList))
onDismiss()
}
) {
@@ -1351,9 +1632,8 @@ class AlertDialogs(
}
}
// Add/Edit message dialog
if (showAddDialog) {
Dialog(
StandardDialog(
onDismissRequest = { showAddDialog = false },
properties = DialogProperties(
usePlatformDefaultWidth = false
@@ -1361,7 +1641,7 @@ class AlertDialogs(
) {
DefaultDialogCard {
Text(
text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["auto_reply_messages.dialog.edit_message"],
text = if (editingIndex == -1) translation["common.add"] ?: "Add Message" else translation["common.edit"] ?: "Edit Message",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
@@ -1373,13 +1653,13 @@ class AlertDialogs(
TextField(
value = editingText,
onValueChange = { editingText = it },
label = { Text(translation["auto_reply_messages.dialog.message_label"]) },
label = { Text(translation["common.message"] ?: "Message") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
minLines = 2,
maxLines = 4,
placeholder = { Text(translation["auto_reply_messages.dialog.message_placeholder"]) }
placeholder = { Text(translation["common.type_message"] ?: "Type message...") }
)
Row(
@@ -1390,32 +1670,22 @@ class AlertDialogs(
) {
Button(
onClick = { showAddDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
if (editingText.isNotBlank()) {
if (editingIndex == -1) {
// Add new message
messageList = messageList.toMutableList().apply {
add(editingText)
}
} else {
// Edit existing message
messageList = messageList.toMutableList().apply {
set(editingIndex, editingText)
}
messageList = messageList.toMutableList().apply {
if (editingIndex == -1) add(editingText) else set(editingIndex, editingText)
}
}
showAddDialog = false
},
enabled = editingText.isNotBlank()
) {
Text(text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["button.save"])
Text(text = if (editingIndex == -1) translation["common.add"] ?: "Add" else translation["button.save"])
}
}
}

View File

@@ -4,12 +4,31 @@ package me.eternal.purrfectsnap.ui.util
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Outline
import android.os.Build
import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.addCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -19,20 +38,31 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.*
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.core.view.WindowCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
import kotlin.math.roundToInt
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext?.findActivity()
else -> null
}
class DialogProperties constructor(
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
@@ -83,6 +113,17 @@ fun Dialog(
content: @Composable () -> Unit
) {
val view = LocalView.current
var forceInline by remember(view) { mutableStateOf(false) }
val hostActivity = remember(view) { view.context.findActivity() }
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
if (shouldUseInline) {
InlineDialog(
onDismissRequest = onDismissRequest,
dismissOnClickOutside = properties.dismissOnClickOutside,
content = content
)
return
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val composition = rememberCompositionContext()
@@ -110,11 +151,30 @@ fun Dialog(
}
DisposableEffect(dialog) {
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
val showDialog = {
try {
dialog.prepareWindowForHost()
if (!dialog.isShowing) {
dialog.show()
}
} catch (_: WindowManager.BadTokenException) {
forceInline = true
}
}
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
showDialog()
} else {
val listener = object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
showDialog()
}
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(listener)
}
dialog.show()
onDispose {
dialog.dismiss()
@@ -131,6 +191,92 @@ fun Dialog(
}
}
@Composable
private fun InlineDialog(
onDismissRequest: () -> Unit,
dismissOnClickOutside: Boolean,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val displayMetrics = LocalContext.current.resources.displayMetrics
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
val fallbackBackDispatcherOwner = remember(onDismissRequest) {
object : OnBackPressedDispatcherOwner {
private val lifecycleRegistry = LifecycleRegistry(this).apply {
currentState = Lifecycle.State.RESUMED
}
private val dispatcher = OnBackPressedDispatcher(onDismissRequest)
override val lifecycle: Lifecycle
get() = lifecycleRegistry
override val onBackPressedDispatcher: OnBackPressedDispatcher
get() = dispatcher
}
}
val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
Popup(
alignment = androidx.compose.ui.Alignment.Center,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = dismissOnClickOutside
),
onDismissRequest = onDismissRequest
) {
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}
}
}
interface DialogWindowProvider {
val window: Window
}
@@ -279,6 +425,26 @@ private class DialogWrapper(
}
}
fun prepareWindowForHost() {
val hostActivity = composeView.context.findActivity()
val hostToken = composeView.applicationWindowToken
?: composeView.windowToken
?: composeView.rootView?.applicationWindowToken
?: composeView.rootView?.windowToken
if (hostActivity == null) {
when {
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
}
}
if (hostToken != null) {
window?.attributes = window?.attributes?.apply {
token = hostToken
}
}
hostActivity?.let { setOwnerActivity(it) }
}
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
dialogLayout.layoutDirection = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
@@ -300,22 +466,26 @@ private class DialogWrapper(
this.onDismissRequest = onDismissRequest
this.properties = properties
setLayoutDirection(layoutDirection)
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
val dialogWindow = window
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
} == true
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
// Undo fixed size in internalOnLayout, which would suppress size changes when
// usePlatformDefaultWidth is true.
window?.setLayout(
dialogWindow.setLayout(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
@OptIn(ExperimentalComposeUiApi::class)
if (properties.decorFitsSystemWindows) {
window?.setSoftInputMode(defaultSoftInputMode)
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
} else {
@Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
}

View File

@@ -2,27 +2,16 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.provider.Settings
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
/**
* Returns true if the user has disabled animator duration scale at the system level.
* This respects Accessibility/Developer settings where motion is reduced or disabled.
*/
fun prefersReducedMotion(context: Context): Boolean {
return runCatching {
@@ -37,9 +26,8 @@ fun prefersReducedMotion(context: Context): Boolean {
@Composable
fun rememberPrefersReducedMotion(): Boolean {
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
val state = remember { mutableStateOf(prefersReducedMotion(context)) }
// One-shot read; if you need to observe changes live, add a ContentObserver.
LaunchedEffect(Unit) {
state.value = prefersReducedMotion(context)
}
@@ -47,18 +35,34 @@ fun rememberPrefersReducedMotion(): Boolean {
}
object Motion {
/**
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
*/
const val HEADER_MORPH_THRESHOLD = 300f
/**
* Milliseconds per pixel for marquee scrolling speed.
*/
const val MARQUEE_CADENCE = 20
// 1. High-end Spring Physics for "Alive" UI
val springDynamic = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
@Composable
fun tweenSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Int> {
fun tweenSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Int> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Int>(durationMillis = d, easing = easing)
return tween(durationMillis = d, easing = easing)
}
@Composable
fun tweenFloatSpec(durationMillis: Int, easing: Easing = androidx.compose.animation.core.FastOutSlowInEasing): FiniteAnimationSpec<Float> {
fun tweenFloatSpec(durationMillis: Int, easing: Easing = FastOutSlowInEasing): FiniteAnimationSpec<Float> {
val reduced = rememberPrefersReducedMotion()
val d = if (reduced) 0 else durationMillis
return tween<Float>(durationMillis = d, easing = easing)
return tween(durationMillis = d, easing = easing)
}
@Composable
@@ -68,23 +72,23 @@ object Motion {
}
/**
* Apply a subtle scale-down on press for clickable components (cards, buttons, tiles).
* Pass the same [interactionSource] into the clickable component to synchronize state.
* Kinetic scale-down on press using spring physics.
*/
@Composable
fun Modifier.scaleOnPress(
interactionSource: InteractionSource,
enabled: Boolean = true,
scaleDown: Float = 0.98f
scaleDown: Float = 0.96f
): Modifier {
val pressed by interactionSource.collectIsPressedAsState()
val target = if (pressed) scaleDown else 1f
val spec = Motion.tweenFloatSpec(150)
val animated by androidx.compose.animation.core.animateFloatAsState(
val animated by animateFloatAsState(
targetValue = target,
animationSpec = spec,
animationSpec = Motion.springDynamic,
label = "pressScale"
)
return this.then(Modifier.graphicsLayer {
scaleX = animated
scaleY = animated

View File

@@ -0,0 +1,26 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A specialized modifier that tracks the height of the header (FloatingTopBar)
* and provides it to the caller. This eliminates repetitive onGloballyPositioned
* logic across all sub-pages.
*
* @param onHeightChanged Callback triggered when the header height is measured.
*/
fun Modifier.headerHeightTracker(
onHeightChanged: (Dp) -> Unit
): Modifier = composed {
val density = LocalDensity.current
this.onGloballyPositioned { coordinates ->
val heightDp = with(density) { coordinates.size.height.toDp() }
onHeightChanged(heightDp)
}
}

View File

@@ -0,0 +1,58 @@
package me.eternal.purrfectsnap.ui.util
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.basicMarquee
import androidx.compose.ui.text.rememberTextMeasurer
/**
* A premium marquee text component that supports continuous looping.
* Optimized using TextMeasurer for robust width calculation.
*/
@Composable
fun PurrfectMarqueeText(
text: String,
style: TextStyle = TextStyle.Default,
modifier: Modifier = Modifier,
color: Color = Color.White,
textAlign: TextAlign = TextAlign.Center,
contentAlignment: Alignment = Alignment.Center,
delayMillis: Int = 1500,
velocity: Dp = 30.dp,
maxLines: Int = 1,
enabled: Boolean = true
) {
Box(
modifier = modifier.clipToBounds(),
contentAlignment = contentAlignment
) {
Text(
text = text,
style = style,
color = color,
textAlign = textAlign,
maxLines = maxLines,
softWrap = true,
overflow = TextOverflow.Ellipsis,
modifier = if (enabled) {
Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
repeatDelayMillis = delayMillis,
initialDelayMillis = delayMillis,
velocity = velocity
)
} else Modifier
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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