diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml
index a0dce0d6..043d22ea 100644
--- a/.github/workflows/debug.yml
+++ b/.github/workflows/debug.yml
@@ -1,29 +1,11 @@
name: PurrfectSnap Debug CI
on:
- push:
- branches:
- - dark-knight
- paths:
- - 'app/**'
- - 'core/**'
- - 'common/**'
- - 'native/**'
- - 'valdi/**'
- - 'mapper/**'
- - 'build.gradle.kts'
- - 'gradle.properties'
- - 'gradle/**'
workflow_dispatch:
inputs:
ci_upload:
description: 'Upload to CI channel'
required: false
type: boolean
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
jobs:
job_armv8:
runs-on: ubuntu-latest
@@ -116,8 +98,7 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
- SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
- mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-$SHORT_SHA.apk
+ mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
@@ -215,94 +196,10 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
- SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
- mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-$SHORT_SHA.apk
+ mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
- name: Upload artifact
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
-
- notify:
- runs-on: ubuntu-latest
- needs: [job_armv8, job_armv7]
- if: always() && github.ref == 'refs/heads/dark-knight'
- steps:
- - name: Checkout repo
- uses: actions/checkout@v4
- with:
- ref: ${{ github.sha }}
- fetch-depth: 0
- - name: Download all artifacts
- uses: actions/download-artifact@v4
- continue-on-error: true
- with:
- path: all-apks
- merge-multiple: true
- - name: Dispatch Modernized Build Card to Telegram
- env:
- TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
- TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_TO }}
- run: |
- sudo apt-get update && sudo apt-get install -y bc jq
- ./gradlew --configuration-cache getVersion || true
- VERSION_NAME=$(cat app/build/version.txt 2>/dev/null || echo "Unknown")
-
- if [[ "${{ needs.job_armv8.result }}" == "success" && "${{ needs.job_armv7.result }}" == "success" ]]; then
- BUILD_STATUS="✅ SUCCESS"
- else
- BUILD_STATUS="❌ FAILED"
- fi
-
- # --- Premium Logic Port ---
- GIT_HASH=$(git rev-parse --short HEAD)
- AUTHOR=$(git log -1 --pretty=%an)
- BRANCH_NAME="${{ github.ref_name }}"
-
- if [[ "$BRANCH_NAME" == "main" ]]; then
- BUILD_TYPE="💎 STABLE RELEASE"
- else
- BUILD_TYPE="🧪 PRE-RELEASE (BETA)"
- fi
-
- CHANGELOG=$(git log -n 5 --pretty=format:"• %s" | sed 's/&/\&/g; s/\</g; s/>/\>/g')
- ACTION_LINK="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-
- MESSAGE="📱 PurrfectSnap | $BRANCH_NAME\n━━━━━━━━━━━━━━━━\n📌 Status: $BUILD_STATUS\n🧪 Type: $BUILD_TYPE\n🆔 Build: #$GIT_HASH\n👤 Author: $AUTHOR\n━━━━━━━━━━━━━━━━\n📝 Latest Commits:\n$CHANGELOG\n━━━━━━━━━━━━━━━━\n📂 Assets & Verification:"
-
- # Discover APKs, calculate sizes/hashes, and handle mirrors
- for apk in $(find all-apks -name "*.apk" -type f); do
- FILENAME=$(basename "$apk")
- FILESIZE_BYTES=$(stat -c%s "$apk")
- FILESIZE_MB=$(echo "scale=1; $FILESIZE_BYTES / 1048576" | bc)
- SHA256_FULL=$(sha256sum "$apk" | awk '{ print $1 }')
- SHA256_SHORT=${SHA256_FULL:0:12}
-
- if [[ "$FILENAME" == *"armv8"* || "$FILENAME" == *"arm64"* ]]; then ARCH="ARMv8"; elif [[ "$FILENAME" == *"armv7"* ]]; then ARCH="ARMv7"; else ARCH="Universal"; fi
-
- # 1. Mirror: GoFile
- SERVER=$(curl -s https://api.gofile.io/servers | jq -r '.data.servers[0].name')
- MIRROR_URL=""
- if [ "$SERVER" != "null" ] && [ -n "$SERVER" ]; then
- UPLOAD_RESP=$(curl -s -F "file=@$apk" "https://${SERVER}.gofile.io/uploadFile")
- MIRROR_URL=$(echo "$UPLOAD_RESP" | jq -r '.data.downloadPage')
- fi
-
- # 2. Mirror Fallback: Catbox
- if [ -z "$MIRROR_URL" ] || [ "$MIRROR_URL" == "null" ]; then
- MIRROR_URL=$(curl -s -F "reqtype=fileupload" -F "fileToUpload=@$apk" https://catbox.moe/user/api.php | tr -d '\r\n')
- fi
-
- MESSAGE="$MESSAGE\n📦 $ARCH ($FILESIZE_MB MB): Download\nSHA256: $SHA256_SHORT"
-
- # Direct Telegram Upload (if size < 50MB)
- if [ "$FILESIZE_BYTES" -lt 50000000 ]; then
- curl -s -F "chat_id=$TELEGRAM_CHAT_ID" -F "document=@$apk" -F "caption=📦 $ARCH Build (#$GIT_HASH)" -F "parse_mode=HTML" "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendDocument" > /dev/null
- fi
- done
-
- MESSAGE="$MESSAGE\n━━━━━━━━━━━━━━━━\n🛠 View GitHub Action"
-
- # Final Dispatch
- curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendMessage" -d "chat_id=$TELEGRAM_CHAT_ID" --data-urlencode "text=$(echo -e "$MESSAGE")" -d "parse_mode=HTML" -d "disable_web_page_preview=true"
diff --git a/.gitignore b/.gitignore
index a234d94e..769477b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,7 +18,3 @@ cloudflare/**/allowed_codes.local.*
security/allowed-codes.local.*
security/allowed_codes.local.*
valdi/node_modules/
-ROADMAP.md
-.github/workflows/upload_telegram.sh
-android9fixes.md
-release.keystore/
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt
index 9ea9018f..c1ad62ec 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt
@@ -86,7 +86,7 @@ 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)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt
index 0bf9c075..33403a6e 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/UpdateCheckWorker.kt
@@ -65,7 +65,7 @@ class UpdateCheckWorker(
val pendingIntent: 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)
.setContentTitle(title)
.setContentText(text.format(versionName))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt
index d3caf9d0..6175515f 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/Navigation.kt
@@ -132,13 +132,14 @@ class Navigation(
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
- val shrinkThreshold = 300f
+ val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
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 = {
@@ -170,7 +171,12 @@ class Navigation(
.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)
}
}
@@ -182,7 +188,10 @@ 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)
}
}
@@ -191,6 +200,7 @@ class Navigation(
}
@Composable
fun FloatingBottomBar() {
+ val haptic = LocalHapticFeedback.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
val availableRoutes = remember {
@@ -198,7 +208,7 @@ class Navigation(
}
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
- val shrinkThreshold = 300f
+ val shrinkThreshold = me.eternal.purrfectsnap.ui.util.Motion.HEADER_MORPH_THRESHOLD
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val barHeight = lerp(82.dp, 64.dp, focusFactor)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
@@ -260,7 +270,7 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
- color = Color.White.copy(alpha = 0.08f), // Restored frosted glass harmony
+ color = Color.White.copy(alpha = 0.08f), // Apply translucent overlay for depth
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(
1.dp,
@@ -499,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()
+ }
)
}
}
@@ -848,3 +861,4 @@ class Navigation(
@Composable fun FloatingActionButton() = Fab()
@Composable fun Content(paddingValues: PaddingValues, startDestination: String) = NavContent(paddingValues, startDestination)
}
+
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt
index 950e2095..4efea7cd 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/components/FloatingTopBar.kt
@@ -74,65 +74,63 @@ fun FloatingTopBar(
colors: FloatingTopBarColors = rememberDefaultFloatingTopBarColors()
) {
val haptic = LocalHapticFeedback.current
+ val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
- // Use standardized morph threshold
- val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
+ // Calculate morphing factor based on scroll progress
+ val focusFactor by remember(scrollOffset) {
+ derivedStateOf { (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f) }
+ }
- // Haptic "Snap" when header hits full expansion/stickiness
+ 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
+ }
+ }
+ }
+
+ // Trigger tactile feedback when header reaches full expansion
var hasSnapped by remember { mutableStateOf(false) }
LaunchedEffect(focusFactor) {
if (focusFactor >= 1f && !hasSnapped) {
- haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
hasSnapped = true
} else if (focusFactor < 0.9f) {
hasSnapped = false
}
}
- val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
-
- // --- GEOMETRIC MORPHING MATH ---
- // 1. Height: 64dp content area when floating -> 56dp when sticky
- val headerHeight = lerp(64.dp, 56.dp, focusFactor)
-
- // 2. Padding Morph:
- // Static: Island (14dp sides, sits BELOW status bar with 12dp margin)
- // Infinity: Full (0dp sides, covers status bar area entirely)
- 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)
-
- // 3. Corner Morph: Round pill (26dp all) -> Bottom-rounded sticky bar (28dp bottom)
- val topCorners = lerp(26.dp, 0.dp, focusFactor)
- val bottomCorners = lerp(26.dp, 28.dp, focusFactor)
- val shape = RoundedCornerShape(
- topStart = topCorners,
- topEnd = topCorners,
- bottomStart = bottomCorners,
- bottomEnd = bottomCorners
- )
-
- // 4. Content Animation: Subtitle falls in/out, icons scale and shift
- 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
+ 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)) {
- // --- 1. REFRACTIVE BACKGROUND (UNDER-GLASS) ---
- // Covers full area (Internal Padding + Content Height + Dissolve Tail)
- val totalHeaderArea = internalTopPadding + headerHeight
- val refractiveColor = Color(0xFF241F52) // Aligned with Palette base
+ // --- 1. Refractive background layer ---
Box(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = sidePadding)
- .padding(top = containerTopPadding)
- .height(totalHeaderArea + 32.dp) // 32dp smooth dissolve tail
+ .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),
@@ -142,12 +140,12 @@ fun FloatingTopBar(
)
)
- // --- 2. MAIN HEADER SURFACE ---
+ // --- 2. Primary header surface ---
Surface(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = sidePadding)
- .padding(top = containerTopPadding)
+ .padding(horizontal = morphingParams.sidePadding)
+ .padding(top = morphingParams.containerTopPadding)
.graphicsLayer {
alpha = containerAlpha
},
@@ -170,8 +168,8 @@ fun FloatingTopBar(
.drawBehind {
val strokeWidth = 1.dp.toPx()
val brush = Brush.linearGradient(listOf(colors.borderStart, colors.borderEnd))
- val tr = topCorners.toPx()
- val br = bottomCorners.toPx()
+ val tr = morphingParams.topCorners.toPx()
+ val br = morphingParams.bottomCorners.toPx()
if (focusFactor > 0.9f) {
uPath.reset()
@@ -201,25 +199,28 @@ fun FloatingTopBar(
}
}
) {
- // --- 3. ROW CONTENT ---
+ // --- 3. Header layout content ---
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(top = internalTopPadding)
- .padding(horizontal = 16.dp, vertical = internalVerticalPadding)
- .height(headerHeight),
+ .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 = onBack,
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onBack()
+ },
modifier = Modifier
.size(44.dp)
.graphicsLayer {
- scaleX = iconScale
- scaleY = iconScale
- translationX = -horizontalShift.toPx()
+ scaleX = morphingParams.iconScale
+ scaleY = morphingParams.iconScale
+ translationX = -morphingParams.horizontalShift.toPx()
}
) {
Icon(
@@ -246,10 +247,10 @@ fun FloatingTopBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
- if (!subtitle.isNullOrBlank() && subtitleAlpha > 0.01f) {
+ if (!subtitle.isNullOrBlank() && morphingParams.subtitleAlpha > 0.01f) {
PurrfectMarqueeText(
text = subtitle,
- color = PurrfectPalette.textSecondary.copy(alpha = subtitleAlpha),
+ color = PurrfectPalette.textSecondary.copy(alpha = morphingParams.subtitleAlpha),
style = TextStyle(fontSize = 13.sp),
textAlign = TextAlign.Start,
contentAlignment = Alignment.CenterStart,
@@ -257,8 +258,8 @@ fun FloatingTopBar(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
- translationY = subtitleTranslationY.toPx()
- alpha = subtitleAlpha
+ translationY = morphingParams.subtitleTranslationY.toPx()
+ alpha = morphingParams.subtitleAlpha
}
)
}
@@ -268,9 +269,9 @@ fun FloatingTopBar(
modifier = Modifier
.wrapContentWidth()
.graphicsLayer {
- scaleX = iconScale
- scaleY = iconScale
- translationX = horizontalShift.toPx()
+ scaleX = morphingParams.iconScale
+ scaleY = morphingParams.iconScale
+ translationX = morphingParams.horizontalShift.toPx()
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -282,10 +283,3 @@ fun FloatingTopBar(
}
}
}
-
-/**
- * Extension to safely copy color with clamped alpha.
- */
-private fun Color.coerceCopy(alpha: Float): Color {
- return this.copy(alpha = alpha.coerceIn(0f, 1f))
-}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt
index 88f2da12..9669cfc4 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt
@@ -14,6 +14,7 @@ object Updater {
val versionName: String,
val releaseUrl: String,
val workflowId: Long?,
+ val body: String? = null,
val assetDownloads: Map = emptyMap(),
)
@@ -38,10 +39,11 @@ object Updater {
private fun fetchLatestRelease(channel: Channel) = runCatching {
val endpoint = Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/releases").build()
val response = OkHttpClient().newCall(endpoint).execute()
+ val body = response.body?.string() ?: throw Throwable("Empty response body")
if (!response.isSuccessful) throw Throwable("Failed to fetch releases: ${response.code}")
- val releases = JsonParser.parseString(response.body?.string()).asJsonArray.also {
+ val releases = JsonParser.parseString(body).asJsonArray.also {
if (it.size() == 0) throw Throwable("No releases found")
}
@@ -80,6 +82,7 @@ object Updater {
releaseUrl = latestRelease.getAsJsonPrimitive("html_url")?.asString
?: endpoint.url.toString().replace("api.", "").replace("repos/", ""),
workflowId = null,
+ body = latestRelease.get("body")?.asString,
assetDownloads = assetDownloads
)
}.onFailure {
@@ -104,27 +107,36 @@ object Updater {
versionName = headSha.substring(0, headSha.length.coerceAtMost(7)) + "-debug",
releaseUrl = latestRun.getAsJsonPrimitive("html_url")?.asString ?: return@runCatching null,
workflowId = latestRun.getAsJsonPrimitive("id")?.asLong,
+ body = latestRun.get("head_commit")?.asJsonObject?.get("message")?.asString
)
}.onFailure {
AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull()
- private val cache = mutableMapOf>()
+ private val cache = java.util.concurrent.ConcurrentHashMap>>()
fun getLatestRelease(channel: Channel): LatestRelease? {
val cached = cache[channel]
- // Use 24-hour TTL (Time To Live) for cache to optimize API calls
- if (cached != null && (System.currentTimeMillis() - cached.first) < 24 * 60 * 60 * 1000) {
- return cached.second
+ val now = System.currentTimeMillis()
+
+ if (cached != null) {
+ val (timestamp, result) = cached
+ // Define Cache TTL: 24 hours for any successful API response, 10 minutes for error
+ val ttl = if (result.isSuccess) 24 * 60 * 60 * 1000L else 10 * 60 * 1000L
+ if ((now - timestamp) < ttl) {
+ return result.getOrNull()
+ }
}
- val result = if (channel == Channel.PRERELEASE) {
- fetchLatestDebugCI() ?: fetchLatestRelease(channel)
- } else {
- fetchLatestRelease(channel)
+ val result = runCatching {
+ if (channel == Channel.PRERELEASE) {
+ fetchLatestDebugCI() ?: fetchLatestRelease(channel)
+ } else {
+ fetchLatestRelease(channel)
+ }
}
- cache[channel] = System.currentTimeMillis() to result
- return result
+ cache[channel] = now to result
+ return result.getOrNull()
}
}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt
index f7e63cc1..72fe4118 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/LoggerHistoryRoot.kt
@@ -232,9 +232,11 @@ class LoggerHistoryRoot : Routes.Route() {
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
attachments.forEachIndexed { index, attachment ->
Button(
onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.coroutineScope.launch {
runCatching {
downloadAttachment(message.sendTimestamp, attachment)
@@ -286,6 +288,7 @@ class LoggerHistoryRoot : Routes.Route() {
}
val conversationInfoCache = remember { ConcurrentHashMap() }
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Box(
modifier = Modifier
@@ -373,6 +376,7 @@ class LoggerHistoryRoot : Routes.Route() {
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
conversations.forEach { conversationId ->
DropdownMenuItem(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
selectedConversation = conversationId
expanded = false
}, text = {
@@ -414,7 +418,10 @@ class LoggerHistoryRoot : Routes.Route() {
},
trailingIcon = if (stringFilter.isNotBlank()) {
{
- IconButton(onClick = { stringFilter = "" }) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ stringFilter = ""
+ }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["close_button_description"],
@@ -446,7 +453,10 @@ class LoggerHistoryRoot : Routes.Route() {
)
Checkbox(
checked = reverseOrder,
- onCheckedChange = { reverseOrder = it },
+ onCheckedChange = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ reverseOrder = it
+ },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
@@ -524,7 +534,7 @@ class LoggerHistoryRoot : Routes.Route() {
FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() },
- scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
+ scrollOffset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt
index c0502168..36f422db 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt
@@ -105,11 +105,12 @@ class TasksRootSection : Routes.Route() {
it.deleteOnExit()
}
- runCatching {
- pendingTask.updateProgress("Copying ${documentFile.name}")
- context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
- val length = documentFile.length().toFloat()
- tempFile.outputStream().use { outputStream ->
+ runCatching {
+ pendingTask.updateProgress("Copying ${documentFile.name}")
+ context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
+ //copy with progress
+ val length = documentFile.length().toFloat()
+ tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024)
var read: Int
while (inputStream.read(buffer).also { read = it } != -1) {
@@ -277,6 +278,7 @@ class TasksRootSection : Routes.Route() {
}
if (showDeleteFiles) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.04f),
@@ -287,33 +289,38 @@ class TasksRootSection : Routes.Route() {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable { onToggleDeleteFiles(!deleteFilesChecked) }
+ .clickable {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onToggleDeleteFiles(!deleteFilesChecked)
+ }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Checkbox(
checked = deleteFilesChecked,
- onCheckedChange = { onToggleDeleteFiles(it) },
+ onCheckedChange = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onToggleDeleteFiles(it)
+ },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White,
checkmarkColor = Color.Black
)
)
- Column {
- Text(
- text = context.translation["delete_files_option"] ?: "Delete Files",
- color = Color.White,
- fontWeight = FontWeight.SemiBold
- )
- Text(
- text = context.translation["delete_files_option_hint"] ?: "Also remove downloaded files",
- color = PurrfectPalette.textSecondary,
- style = MaterialTheme.typography.bodySmall
- )
- }
- }
+ Column {
+ Text(
+ text = translation["delete_files_option"] ?: "Delete Files",
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = translation["delete_files_option_hint"] ?: "Also remove downloaded files",
+ color = PurrfectPalette.textSecondary,
+ style = MaterialTheme.typography.bodySmall
+ )
+ } }
}
}
@@ -321,8 +328,12 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Button(
- onClick = onDismiss,
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onDismiss()
+ },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
@@ -331,7 +342,10 @@ class TasksRootSection : Routes.Route() {
Text(context.translation["button.negative"])
}
Button(
- onClick = onConfirm,
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onConfirm()
+ },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
@@ -452,10 +466,12 @@ class TasksRootSection : Routes.Route() {
}
val isActive = pendingTask != null && !taskStatus.isFinalStage()
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val cardModifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
toggleSelection()
return@detectTapGestures
@@ -463,6 +479,7 @@ class TasksRootSection : Routes.Route() {
openFile()
},
onLongPress = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isNotEmpty()) {
openFile()
return@detectTapGestures
@@ -624,9 +641,11 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.weight(1f),
) {
if (task.type == TaskType.SCHEDULED_SEND) {
+ // Professional design for scheduled send tasks
Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
+ // Feature title
Text(
context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps",
style = MaterialTheme.typography.labelMedium,
@@ -634,6 +653,7 @@ class TasksRootSection : Routes.Route() {
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
+ // Scheduled time without icon
Text(
task.title,
style = MaterialTheme.typography.titleMedium,
@@ -641,6 +661,7 @@ class TasksRootSection : Routes.Route() {
color = Color.White
)
+ // Recipients with icon
task.author?.takeIf { it != "null" }?.let { recipients ->
Row(
verticalAlignment = Alignment.Top,
@@ -856,6 +877,7 @@ class TasksRootSection : Routes.Route() {
scrollOffset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
shape = RoundedCornerShape(50),
@@ -863,6 +885,7 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier
.padding(end = 8.dp)
.clickable {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
@@ -890,6 +913,7 @@ class TasksRootSection : Routes.Route() {
}
}
IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (taskSelection.isEmpty()) {
showConfirmDialog = true
} else {
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt
index e90431e5..24a3678a 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigExportSummaryScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
+import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
@@ -56,14 +57,17 @@ class ConfigExportSummaryScreen : Routes.Route() {
for (key in properties.keys()) {
val value = properties.get(key)
val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
+ // Handle nested features with their own state and sub-properties
if (value is JSONObject && value.has("state") && value.has("properties")) {
val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
val featureName = context.translation[featureNameKey] ?: key
featureList.add(ImportedFeature(niceCategoryName, featureName, key, value.getBoolean("state"), indent))
parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent + 1)
} else if (value is JSONObject && value.has("properties")) {
+ // Handle purely structural containers
parseProperties(categoryKey, niceCategoryName, value.getJSONObject("properties"), currentPrefix, indent)
} else {
+ // Handle terminal leaf properties (strings, ints, etc.)
val featureNameKey = "features.properties.$categoryKey.properties.${currentPrefix.split('.').joinToString(".properties.")}.name"
var featureName = context.translation[featureNameKey] ?: key
if (key == "save_folder") {
@@ -77,6 +81,7 @@ class ConfigExportSummaryScreen : Routes.Route() {
val value = json.get(categoryKey)
if (value is JSONObject) {
val niceCategoryName = context.translation["features.properties.$categoryKey.name"] ?: categoryKey.replaceFirstChar { it.uppercase() }
+ // Process top-level features or recursively descend into property containers
if (value.has("state") && !value.has("properties")) {
featureList.add(ImportedFeature(niceCategoryName, translation["enable_feature"], categoryKey, value.getBoolean("state"), 0))
} else if (value.has("properties")) {
@@ -117,16 +122,20 @@ class ConfigExportSummaryScreen : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
- val exportSensitiveData = it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false
- val includeSavedLocations = it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false
+ var exportSensitiveData by remember { mutableStateOf(it.arguments?.getString("exportSensitiveData")?.toBoolean() ?: false) }
+ var includeSavedLocations by remember { mutableStateOf(it.arguments?.getString("includeSavedLocations")?.toBoolean() ?: false) }
+
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
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, includeSavedLocations, savedLocations))
+
+ val featuresByCategory by remember(exportSensitiveData, includeSavedLocations) {
+ derivedStateOf {
+ val savedLocations = if (includeSavedLocations) context.database.getLocationCoordinates() else null
+ parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
+ }
}
+
val expandedState = remember { mutableStateMapOf() }
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -148,13 +157,70 @@ class ConfigExportSummaryScreen : Routes.Route() {
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
+ item {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ shape = RoundedCornerShape(18.dp),
+ color = PurrfectPalette.cardOverlayColor,
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
+ ) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = context.translation["manager.dialogs.export_config.content"] ?: "Export Sensitive Data",
+ color = Color.White,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium
+ )
+ Switch(
+ checked = exportSensitiveData,
+ onCheckedChange = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ exportSensitiveData = it
+ },
+ colors = purrfectSwitchColors()
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = context.translation["manager.sections.features.include_saved_locations"] ?: "Include Saved Locations",
+ color = Color.White,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium
+ )
+ Switch(
+ checked = includeSavedLocations,
+ onCheckedChange = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ includeSavedLocations = it
+ },
+ colors = purrfectSwitchColors()
+ )
+ }
+ }
+ }
+ }
+
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
modifier = Modifier
.fillMaxWidth()
- .clickable { expandedState[category] = !isExpanded },
+ .clickable {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ expandedState[category] = !isExpanded
+ },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
@@ -179,7 +245,10 @@ class ConfigExportSummaryScreen : Routes.Route() {
color = Color.White
)
}
- IconButton(onClick = { expandedState[category] = !isExpanded }) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ expandedState[category] = !isExpanded
+ }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
@@ -259,14 +328,16 @@ class ConfigExportSummaryScreen : Routes.Route() {
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
- scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
+ scrollOffset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
+ val savedLocations = if (includeSavedLocations) context.database.getLocationCoordinates() else null
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt
index 2bfa008f..ec43d95d 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ConfigImportConfirmationScreen.kt
@@ -56,6 +56,10 @@ class ConfigImportConfirmationScreen : Routes.Route() {
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()
@@ -66,11 +70,13 @@ class ConfigImportConfirmationScreen : Routes.Route() {
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
}
+ // No duplicate found, add as new location
if (existingMatch == null) {
val newLocation = LocationCoordinates().apply {
this.name = name
@@ -80,6 +86,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
context.database.addOrUpdateLocationCoordinate(null, newLocation)
}
+ // If duplicate exists, skip (do not update or delete existing)
}
}
@@ -97,6 +104,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
for (key in properties.keys()) {
val value = properties.get(key)
val currentPrefix = if (prefix.isEmpty()) key else "$prefix.$key"
+ // Handle nested features with their own state and sub-properties
if (value is JSONObject && value.has("state") && value.has("properties")) {
val featureNameKey =
"features.properties.$categoryKey.properties.${currentPrefix.split('.')
@@ -119,6 +127,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
indent + 1
)
} else if (value is JSONObject && value.has("properties")) {
+ // Handle purely structural containers
parseProperties(
categoryKey,
niceCategoryName,
@@ -127,6 +136,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
indent
)
} else {
+ // Handle terminal leaf properties (strings, ints, etc.)
val featureNameKey =
"features.properties.$categoryKey.properties.${currentPrefix.split('.')
.joinToString(".properties.")}.name"
@@ -149,6 +159,7 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val niceCategoryName =
context.translation["features.properties.$categoryKey.name"]
?: categoryKey.replaceFirstChar { it.uppercase() }
+ // Process top-level features or recursively descend into property containers
if (value.has("state") && !value.has("properties")) {
featureList.add(
ImportedFeature(
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt
index e7467236..61e1a469 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt
@@ -367,10 +367,14 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun PropertyAction(property: PropertyPair<*>, registerClickCallback: RegisterClickCallback) {
+ val hapticFeedback = LocalHapticFeedback.current
var showDialog by remember { mutableStateOf(false) }
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
- fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
+ fun registerDialogOnClickCallback() = registerClickCallback {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ showDialog = true
+ }
if (showDialog) {
Dialog(
@@ -472,6 +476,7 @@ class FeaturesRootSection : Routes.Route() {
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
selectedFile = if (isSelected) null else file.name
propertyValue.setAny(selectedFile)
persistConfig()
@@ -540,6 +545,7 @@ class FeaturesRootSection : Routes.Route() {
if (property.key.params.flags.contains(ConfigFlag.FOLDER)) {
IconButton(onClick = registerClickCallback {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
activityLauncher {
chooseFolder { uri ->
propertyValue.setAny(uri)
@@ -555,13 +561,10 @@ class FeaturesRootSection : Routes.Route() {
when (val dataType = remember { property.key.dataType.type }) {
DataProcessors.Type.BOOLEAN -> {
var state by remember { mutableStateOf(propertyValue.get() as Boolean) }
- val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = registerClickCallback {
- if (context.config.root.global.uiSettings.hapticFeedback.get()) {
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
state = state.not()
propertyValue.setAny(state)
persistConfig()
@@ -612,7 +615,7 @@ class FeaturesRootSection : Routes.Route() {
alertDialogs.MultipleSelectionDialog(property)
}
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
- // Check if this is a message list property
+ // Verify if property handles message lists
val isMessageListProperty = property.key.name.endsWith("_messages")
if (isMessageListProperty) {
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
@@ -632,7 +635,7 @@ class FeaturesRootSection : Routes.Route() {
onClick = it
)
} else {
- // Check if this is a message list property
+ // Verify if property handles message lists
val isMessageListProperty = property.key.name.endsWith("_messages")
if (isMessageListProperty) {
// Show message count
@@ -648,17 +651,24 @@ class FeaturesRootSection : Routes.Route() {
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
- border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
+ border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
+ modifier = Modifier.clickable {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ it()
+ }
) {
Text(
- text = "$messageCount messages",
+ text = translation.format("search_results_count", "count" to messageCount.toString()),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
} else {
- IconButton(onClick = it) {
+ IconButton(onClick = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ it()
+ }) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
}
}
@@ -682,6 +692,7 @@ class FeaturesRootSection : Routes.Route() {
val container = propertyValue.get() as ConfigContainer
registerClickCallback {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
routes.navController.navigate(FEATURE_CONTAINER_ROUTE.replace("{name}", property.name))
}
@@ -703,13 +714,10 @@ class FeaturesRootSection : Routes.Route() {
))
}
- val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
- if (context.config.root.global.uiSettings.hapticFeedback.get()) {
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
state = state.not()
container.globalState = state
persistConfig()
@@ -726,11 +734,15 @@ class FeaturesRootSection : Routes.Route() {
text: String,
onClick: () -> Unit
) {
+ val haptic = LocalHapticFeedback.current
Surface(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
- .clickable { onClick() },
+ .clickable {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onClick()
+ },
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
@@ -961,6 +973,7 @@ class FeaturesRootSection : Routes.Route() {
var showExportDialog by remember { mutableStateOf(false) }
if (showResetConfirmationDialog) {
+ val haptic = LocalHapticFeedback.current
Dialog(onDismissRequest = { showResetConfirmationDialog = false }) {
Surface(
shape = RoundedCornerShape(24.dp),
@@ -997,7 +1010,10 @@ class FeaturesRootSection : Routes.Route() {
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
Button(
- onClick = { showResetConfirmationDialog = false },
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showResetConfirmationDialog = false
+ },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
@@ -1007,6 +1023,7 @@ class FeaturesRootSection : Routes.Route() {
}
Button(
onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
context.config.reset()
context.shortToast(context.translation["manager.dialogs.reset_config.success_toast"] ?: "Reset successful")
showResetConfirmationDialog = false
@@ -1028,19 +1045,25 @@ class FeaturesRootSection : Routes.Route() {
if (showExportDialog) {
SensitiveDataDialog(
onDismiss = { showExportDialog = false },
- onConfirm = { exportSensitiveData ->
+ onConfirm = { exportSensitiveData, includeSavedLocations ->
showExportDialog = false
routes.configExportSummary.navigate {
put("exportSensitiveData", exportSensitiveData.toString())
+ put("includeSavedLocations", includeSavedLocations.toString())
}
}
)
}
+ val haptic = LocalHapticFeedback.current
val actions = remember {
listOf(
- Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) { showExportDialog = true },
+ Triple(translation["export_option"] ?: "Export", Icons.Filled.SaveAlt) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showExportDialog = true
+ },
Triple(translation["import_option"] ?: "Import", Icons.Filled.FileDownload) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
activityLauncher {
openFile("application/json") { uriString ->
runCatching {
@@ -1057,7 +1080,10 @@ class FeaturesRootSection : Routes.Route() {
}
},
- Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) { showResetConfirmationDialog = true }
+ Triple(translation["reset_option"] ?: "Reset", Icons.Filled.Refresh) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showResetConfirmationDialog = true
+ }
)
}
@@ -1207,7 +1233,7 @@ class FeaturesRootSection : Routes.Route() {
}
)
- // Suggestions overlay (outside the TopBar)
+ // Search suggestions overlay container
if (showSearchBar && combinedSuggestions.isNotEmpty()) {
Surface(
modifier = Modifier
@@ -1375,9 +1401,11 @@ class FeaturesRootSection : Routes.Route() {
@Composable
private fun SensitiveDataDialog(
onDismiss: () -> Unit,
- onConfirm: (exportSensitiveData: Boolean) -> Unit
+ onConfirm: (exportSensitiveData: Boolean, includeSavedLocations: Boolean) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
+ val includeSavedLocations = remember { mutableStateOf(false) }
+
Surface(
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -1415,12 +1443,40 @@ class FeaturesRootSection : Routes.Route() {
color = PurrfectPalette.textSecondary,
modifier = Modifier.padding(horizontal = 6.dp)
)
+
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = translation["include_saved_locations"] ?: "Include Saved Locations",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White
+ )
+ val hapticFeedback = LocalHapticFeedback.current
+ Switch(
+ checked = includeSavedLocations.value,
+ onCheckedChange = {
+ if (context.config.root.global.uiSettings.hapticFeedback.get()) {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ includeSavedLocations.value = it
+ },
+ colors = purrfectSwitchColors()
+ )
+ }
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
+ val haptic = LocalHapticFeedback.current
Button(
- onClick = { onConfirm(false) },
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onConfirm(false, includeSavedLocations.value)
+ },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
@@ -1429,7 +1485,10 @@ class FeaturesRootSection : Routes.Route() {
Text(context.translation["button.negative"])
}
Button(
- onClick = { onConfirm(true) },
+ onClick = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onConfirm(true, includeSavedLocations.value)
+ },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt
index 3d40fd76..2177844a 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeAbout.kt
@@ -55,6 +55,7 @@ class HomeAbout : Routes.Route() {
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
LaunchedEffect(Unit) {
context.shortToast(translation["about_magic_toast"])
@@ -104,6 +105,7 @@ class HomeAbout : Routes.Route() {
interactionSource = tapSource,
indication = null
) {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
val now = SystemClock.elapsedRealtime()
if (now - lastTapTime.longValue > tapTimeoutMs) {
tapCount.intValue = 0
@@ -139,12 +141,14 @@ class HomeAbout : Routes.Route() {
name = translation["about_dev_external"],
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
+ haptic = haptic,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = translation["about_dev_rsr"],
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
+ haptic = haptic,
modifier = Modifier.weight(1f)
)
}
@@ -222,6 +226,7 @@ class HomeAbout : Routes.Route() {
Button(
modifier = Modifier.weight(1f),
onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.androidContext.openLink(
"https://github.com/particle-box/PurrfectSnap",
context.translation["toast_open_link_failed"]
@@ -243,6 +248,7 @@ class HomeAbout : Routes.Route() {
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
context.androidContext.openLink(
"https://t.me/purrfectsnap_official",
context.translation["toast_open_link_failed"]
@@ -279,6 +285,7 @@ class HomeAbout : Routes.Route() {
name: String,
imageRes: Int,
avenirNext: FontFamily,
+ haptic: androidx.compose.ui.hapticfeedback.HapticFeedback,
modifier: Modifier = Modifier
) {
val cardShape = RoundedCornerShape(20.dp)
@@ -290,7 +297,9 @@ class HomeAbout : Routes.Route() {
)
Surface(
- modifier = modifier,
+ modifier = modifier.clickable {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ },
shape = cardShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt
index 38060e48..a6c8b747 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeRootSection.kt
@@ -1,13 +1,23 @@
package me.eternal.purrfectsnap.ui.manager.pages.home
import android.content.SharedPreferences
-import androidx.compose.animation.*
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
-import androidx.compose.foundation.*
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalIndication
+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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Widgets
@@ -20,14 +30,16 @@ 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.*
+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.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -37,8 +49,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.unit.lerp
import androidx.compose.ui.zIndex
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
@@ -55,14 +67,19 @@ 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.components.AestheticDialog
+import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
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.theme.PurrfectPalette
-import me.eternal.purrfectsnap.ui.util.*
+import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
+import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
+import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
+import me.eternal.purrfectsnap.ui.util.scaleOnPress
+import me.eternal.purrfectsnap.ui.util.Motion
+import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient
import okhttp3.Request
+import androidx.compose.foundation.ScrollState
class HomeRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home") }
@@ -129,13 +146,17 @@ class HomeRootSection : Routes.Route() {
onClick: (() -> Unit)? = null,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
containerColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
+ haptic: androidx.compose.ui.hapticfeedback.HapticFeedback
) {
val interactionSource = remember { MutableInteractionSource() }
val clickModifier = if (onClick != null) {
Modifier.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current
- ) { onClick() }
+ ) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onClick()
+ }
} else {
Modifier
}
@@ -178,12 +199,14 @@ class HomeRootSection : Routes.Route() {
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
+ haptic: androidx.compose.ui.hapticfeedback.HapticFeedback,
onClick: () -> Unit,
) {
- val chipShape = RoundedCornerShape(40)
Surface(
- modifier = Modifier.widthIn(min = 36.dp),
- shape = chipShape,
+ modifier = Modifier
+ .height(36.dp)
+ .widthIn(min = 36.dp), // Harmonized minimum footprint
+ shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
1.dp,
@@ -197,10 +220,13 @@ class HomeRootSection : Routes.Route() {
) {
Row(
modifier = Modifier
- .clip(chipShape)
- .clickable(onClick = onClick)
+ .clip(RoundedCornerShape(40))
+ .clickable {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ onClick()
+ }
.padding(
- vertical = 6.dp,
+ vertical = 6.dp, // Fixed height to prevent enlarging
horizontal = lerp(10.dp, 12.dp, shrinkFactor)
),
verticalAlignment = Alignment.CenterVertically,
@@ -216,51 +242,52 @@ class HomeRootSection : Routes.Route() {
scaleY = iconScale
}
)
+ // Fluid Label Morph: Continuous alpha and width to prevent jumping
if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
- if (labelAlpha > 0f) {
- Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
- Text(
- text = label,
- color = Color.White.copy(alpha = labelAlpha),
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = TextOverflow.Clip,
- modifier = Modifier
- .graphicsLayer {
- alpha = labelAlpha
- translationX = (-4 * (1f - shrinkFactor)).dp.toPx()
- }
- .widthIn(max = (75 * shrinkFactor).dp)
- )
- }
+ Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
+ Text(
+ text = label,
+ color = Color.White.copy(alpha = labelAlpha),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Clip,
+ modifier = Modifier
+ .graphicsLayer {
+ alpha = labelAlpha
+ translationX = (-4 * (1f - shrinkFactor)).dp.toPx()
+ }
+ .widthIn(max = (75 * shrinkFactor).dp)
+ )
}
}
}
}
@Composable
- private fun RowScope.HomeActionChips(scrollState: ScrollState) {
- val shrinkFactor by remember {
+ private fun RowScope.HomeActionChips(scrollState: ScrollState, haptic: androidx.compose.ui.hapticfeedback.HapticFeedback) {
+ // Optimize: Use derivedStateOf to prevent constant re-composition during scroll
+ val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
TopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
- shrinkFactor = shrinkFactor
+ shrinkFactor = shrinkFactor,
+ haptic = haptic
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
- shrinkFactor = shrinkFactor
+ shrinkFactor = shrinkFactor,
+ haptic = haptic
) { routes.settings.navigate() }
}
@Composable
- private fun LivingPurrAura(isActive: Boolean) {
- val haptic = LocalHapticFeedback.current
+ private fun LivingPurrAura(isActive: Boolean, haptic: androidx.compose.ui.hapticfeedback.HapticFeedback) {
val infiniteTransition = rememberInfiniteTransition(label = "aura")
val pulseScale by infiniteTransition.animateFloat(
@@ -273,16 +300,27 @@ class HomeRootSection : Routes.Route() {
label = "pulse"
)
- val glowAlpha1 by infiniteTransition.animateFloat(
+ val glow1 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
animationSpec = infiniteRepeatable(tween(3200, easing = LinearEasing), RepeatMode.Restart),
label = "g1"
)
- val glowAlpha2 by infiniteTransition.animateFloat(
+ val glow2 by infiniteTransition.animateFloat(
initialValue = 0f, targetValue = 1f,
- animationSpec = infiniteRepeatable(tween(3200, delayMillis = 1100, easing = LinearEasing), RepeatMode.Restart),
+ animationSpec = infiniteRepeatable(
+ animation = tween(3200, delayMillis = 1100, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
label = "g2"
)
+ val glow3 by infiniteTransition.animateFloat(
+ initialValue = 0f, targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(3200, delayMillis = 2200, easing = LinearEasing),
+ repeatMode = RepeatMode.Restart
+ ),
+ label = "g3"
+ )
val coreColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowPrimary else Color(0xFF8C8CA3),
@@ -290,40 +328,41 @@ class HomeRootSection : Routes.Route() {
)
val secondaryColor by animateColorAsState(
targetValue = if (isActive) PurrfectPalette.glowSecondary else Color(0xFF6B6B7A),
- animationSpec = tween(800), label = "secColor"
+ animationSpec = tween(800), label = "coreColor"
)
- LaunchedEffect(glowAlpha1) {
- if (isActive && glowAlpha1 < 0.05f) {
- haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
- }
- }
-
Canvas(modifier = Modifier.size(44.dp)) {
val center = Offset(size.width / 2, size.height / 2)
val baseRadius = 6.dp.toPx()
- fun drawGlow(progress: Float, alpha: Float) {
+ fun drawAuroraGlow(progress: Float, alphaMultiplier: Float) {
if (!isActive || progress <= 0f) return
- val radius = baseRadius * (1.2f + 4.5f * progress)
+ val auroraRadius = baseRadius * (1.2f + 4.5f * progress)
drawCircle(
brush = Brush.radialGradient(
- 0.0f to coreColor.copy(alpha = 0.25f * (1f - progress) * alpha),
- 0.6f to secondaryColor.copy(alpha = 0.12f * (1f - progress) * alpha),
+ 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 = radius
+ center = center,
+ radius = auroraRadius
),
- radius = radius, center = center
+ radius = auroraRadius,
+ center = center
)
}
- drawGlow(glowAlpha1, 0.8f)
- drawGlow(glowAlpha2, 0.5f)
+ drawAuroraGlow(glow1, 0.8f)
+ drawAuroraGlow(glow2, 0.5f)
+ drawAuroraGlow(glow3, 0.3f)
drawCircle(
- brush = Brush.radialGradient(listOf(coreColor, secondaryColor), center = center, radius = baseRadius * pulseScale),
- radius = baseRadius * pulseScale, center = center
+ brush = Brush.radialGradient(
+ colors = listOf(coreColor, secondaryColor),
+ center = center,
+ radius = baseRadius * pulseScale
+ ),
+ radius = baseRadius * pulseScale,
+ center = center
)
drawCircle(
@@ -346,7 +385,8 @@ class HomeRootSection : Routes.Route() {
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
- scrollOffset: () -> Int
+ scrollOffset: () -> Int,
+ haptic: androidx.compose.ui.hapticfeedback.HapticFeedback
) {
val heroShape = RoundedCornerShape(36.dp)
val gitHashShort = remember { (context.installationSummary.modInfo?.gitHash ?: BuildConfig.GIT_HASH).take(7) }
@@ -440,7 +480,10 @@ class HomeRootSection : Routes.Route() {
when (state) {
UpdateDownloader.DownloadState.IDLE,
UpdateDownloader.DownloadState.FAILED -> {
- Button(onClick = onUpdateAction, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))) {
+ 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))
}
}
@@ -492,7 +535,7 @@ class HomeRootSection : Routes.Route() {
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) {
- LivingPurrAura(isActive = isPurrAuraActive)
+ LivingPurrAura(isActive = isPurrAuraActive, haptic = haptic)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
@@ -505,7 +548,10 @@ class HomeRootSection : Routes.Route() {
}
OutlinedButton(
- onClick = onAboutClick,
+ 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),
@@ -538,7 +584,10 @@ class HomeRootSection : Routes.Route() {
val androidContext = context.androidContext
Button(
modifier = Modifier.weight(1f).height(44.dp),
- onClick = { androidContext.openLink("https://purrfectsnap.me", context.translation["toast_open_link_failed"]) },
+ 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)
) {
@@ -554,7 +603,10 @@ class HomeRootSection : Routes.Route() {
}
OutlinedButton(
modifier = Modifier.weight(1f).height(44.dp),
- onClick = { androidContext.openLink("https://github.com/particle-box/PurrfectSnap", context.translation["toast_open_link_failed"]) },
+ 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)
@@ -569,7 +621,10 @@ class HomeRootSection : Routes.Route() {
)
}
}
- ExternalLinkIcon(imageVector = ImageVector.vectorResource(id = R.drawable.ic_telegram), onClick = { androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) }, tint = Color.White, containerColor = Color.White.copy(alpha = 0.14f))
+ 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)
}
}
}
@@ -580,8 +635,9 @@ class HomeRootSection : Routes.Route() {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
- @OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+ @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
override val content: @Composable (NavBackStackEntry) -> Unit = {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val avenirNext = remember { FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) }
val cards = rememberCards()
val selectedTiles = rememberAsyncMutableStateList(defaultValue = listOf()) { context.database.getQuickTiles().filter { it.isNotBlank() } }
@@ -632,11 +688,15 @@ class HomeRootSection : Routes.Route() {
Box(modifier = Modifier.fillMaxSize().background(pageBackgroundGradient)) {
// FLOATING HEADER OVERLAY
- val focusFactor = (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
- val stickyBrandingAlpha = ((scrollState.value.toFloat() - 50f) / 100f).coerceIn(0f, 1f)
+ 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()
- // a438a7e & Particle-Box Symmetry Standards
+ // Standard header dimensions and layout constants
val headerHeight = lerp(54.dp, 56.dp, focusFactor)
val sidePadding = 0.dp
val containerTopPadding = lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
@@ -647,8 +707,8 @@ class HomeRootSection : Routes.Route() {
// Header Container
Box(modifier = Modifier.fillMaxWidth().zIndex(10f)) {
- // "Under-Glass" Refractive Dissolve Layer
- val refractiveColor = Color(0xFF241F52)
+ // Refractive background layer
+ val refractiveColor = remember { Color(0xFF241F52) }
Box(
modifier = Modifier
.fillMaxWidth()
@@ -664,7 +724,7 @@ class HomeRootSection : Routes.Route() {
)
)
- // 1. Background Sticky Bar (Fades in)
+ // Sticky background surface for floating header
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -722,25 +782,25 @@ class HomeRootSection : Routes.Route() {
.padding(horizontal = 16.dp, vertical = internalVerticalPadding)
.height(headerHeight)
) {
- // Original Logo Sticky Branding
- Image(
- painter = painterResource(id = R.drawable.logo),
- contentDescription = "PurrfectSnap",
- modifier = Modifier
- .align(Alignment.Center)
- .size(42.dp)
- .graphicsLayer {
- alpha = stickyBrandingAlpha
- }
+ // Branding text visible when header is sticky
+ Text(
+ text = "PurrfectSnap",
+ color = Color.White.copy(alpha = stickyBrandingAlpha),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = avenirNext,
+ modifier = Modifier.align(Alignment.Center)
)
- // LEFT: Announcement
- val announcementShift = with(LocalDensity.current) { (-6 * focusFactor).dp.toPx() }
+ // Left-aligned announcement interaction chip
+ val announcementShift by remember(focusFactor) {
+ derivedStateOf { (-6 * focusFactor).dp }
+ }
Row(
modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer {
- translationX = announcementShift
+ translationX = announcementShift.toPx()
},
verticalAlignment = Alignment.CenterVertically
) {
@@ -748,25 +808,28 @@ class HomeRootSection : Routes.Route() {
icon = Icons.Filled.Notifications,
label = null,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
- contentDescription = translation["announcements_button_description"]
+ contentDescription = translation["announcements_button_description"],
+ haptic = haptic
) {
showAnnouncementsDialog = true
loadAnnouncements()
}
}
- // RIGHT: Logs & Settings (Group)
- val settingsShift = with(LocalDensity.current) { (6 * focusFactor).dp.toPx() }
+ // Right-aligned action chips for system navigation
+ val settingsShift by remember(focusFactor) {
+ derivedStateOf { (6 * focusFactor).dp }
+ }
Row(
modifier = Modifier
.align(Alignment.CenterEnd)
.graphicsLayer {
- translationX = settingsShift
+ translationX = settingsShift.toPx()
},
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
- HomeActionChips(scrollState = scrollState)
+ HomeActionChips(scrollState = scrollState, haptic = haptic)
}
}
}
@@ -785,7 +848,8 @@ class HomeRootSection : Routes.Route() {
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,
- scrollOffset = { scrollState.value }
+ scrollOffset = { scrollState.value },
+ haptic = haptic
)
Spacer(Modifier.height(12.dp))
@@ -799,7 +863,10 @@ class HomeRootSection : Routes.Route() {
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 = { showQuickActionsMenu = true }, colors = ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color(0xFF1B152E))) {
+ 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"])
@@ -809,7 +876,10 @@ class HomeRootSection : Routes.Route() {
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 = { showQuickActionsMenu = true }, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.3f)), colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)) {
+ 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"])
@@ -857,7 +927,10 @@ class HomeRootSection : Routes.Route() {
.width(100.dp)
.aspectRatio(1.05f) // Restored to 1.05f for square look
.scaleOnPress(interactionSource)
- .clickable { cardEntry.value(routes) },
+ .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))
@@ -889,16 +962,38 @@ class HomeRootSection : Routes.Route() {
}
if (showAnnouncementsDialog) {
- AestheticDialog(onDismissRequest = { showAnnouncementsDialog = false }, title = "Announcements", text = "", icon = Icons.Filled.Info, confirmButtonText = "Close", onConfirm = { showAnnouncementsDialog = false }, customContent = {
- Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- if (announcementsLoading) CircularProgressIndicator(color = Color.White)
- else Text(announcementsText ?: "No announcements", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
+ AestheticDialog(
+ onDismissRequest = { showAnnouncementsDialog = false },
+ title = translation["announcements_dialog_title"] ?: "Announcements",
+ text = "",
+ icon = Icons.Filled.Notifications,
+ confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
+ onConfirm = { showAnnouncementsDialog = false },
+ customContent = {
+ Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ if (announcementsLoading) CircularProgressIndicator(color = Color.White)
+ else Text(announcementsText ?: translation["announcements_dialog_empty"] ?: "No announcements", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
+ }
}
- })
+ )
}
if (showChangelogDialog) {
- AestheticDialog(onDismissRequest = { showChangelogDialog = false }, title = "Changelog", text = "Latest refinements and stability fixes.", icon = Icons.Filled.Info, confirmButtonText = "Update", onConfirm = { showChangelogDialog = false; handleUpdateAction() }, dismissButtonText = "Cancel", onDismiss = { showChangelogDialog = false })
+ val haptic = LocalHapticFeedback.current
+ AestheticDialog(
+ onDismissRequest = { showChangelogDialog = false },
+ title = translation["changelog_dialog_title"],
+ text = latestUpdate?.body ?: translation["changelog_dialog_empty"],
+ icon = Icons.Filled.Info,
+ confirmButtonText = translation["changelog_dialog_update_button"],
+ onConfirm = {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showChangelogDialog = false
+ handleUpdateAction()
+ },
+ dismissButtonText = translation["changelog_dialog_cancel_button"],
+ onDismiss = { showChangelogDialog = false }
+ )
}
if (showQuickActionsMenu) {
@@ -906,3 +1001,4 @@ class HomeRootSection : Routes.Route() {
}
}
}
+
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt
index fa3ff376..f84b8d4a 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt
@@ -174,6 +174,7 @@ class HomeSettings : Routes.Route() {
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
value = false
sharedPreferences.edit().putBoolean(realKey, false).apply()
showDisableDialog = false
@@ -188,9 +189,7 @@ class HomeSettings : Routes.Route() {
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
- if (context.config.root.global.uiSettings.hapticFeedback.get()) {
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
val nextValue = !value
if (!nextValue && confirmDisableTitle != null) {
showDisableDialog = true
@@ -224,9 +223,7 @@ class HomeSettings : Routes.Route() {
.fillMaxWidth()
.heightIn(min = 55.dp)
.clickable {
- if (context.config.root.global.uiSettings.hapticFeedback.get()) {
- hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
- }
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
value = !value
sharedPreferences
.edit() {
@@ -248,10 +245,12 @@ class HomeSettings : Routes.Route() {
@Composable
private fun RowAction(key: String, requireConfirmation: Boolean = false, action: () -> Unit) {
+ val hapticFeedback = LocalHapticFeedback.current
var confirmationDialog by remember {
mutableStateOf(false)
}
fun takeAction() {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (requireConfirmation) {
confirmationDialog = true
} else {
@@ -261,6 +260,7 @@ class HomeSettings : Routes.Route() {
if (requireConfirmation && confirmationDialog) {
Dialog(onDismissRequest = { confirmationDialog = false }) {
dialogs.ConfirmDialog(title = context.translation["manager.dialogs.action_confirm.title"], onConfirm = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
action()
confirmationDialog = false
}, onDismiss = {
@@ -315,6 +315,7 @@ class HomeSettings : Routes.Route() {
val contextC = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
+ val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
@@ -421,7 +422,6 @@ class HomeSettings : Routes.Route() {
) {
Text(text = translation["haptic_feedback_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
- val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
@@ -445,7 +445,6 @@ class HomeSettings : Routes.Route() {
) {
Text(text = translation["use_system_toasts_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
- val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
@@ -477,7 +476,6 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
- val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
@@ -887,7 +885,10 @@ class HomeSettings : Routes.Route() {
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
- IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
+ IconButton(onClick = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ routes.navigation?.openBottomBarCustomization = true
+ }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt
index 31533424..76796fac 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/QuickActionsDialog.kt
@@ -53,6 +53,7 @@ fun QuickActionsDialog(
translation: LocaleWrapper
) {
val selected = remember { mutableStateListOf(*selectedQuickActions.toTypedArray()) }
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Dialog(onDismissRequest = onDismiss) {
val dialogShape = RoundedCornerShape(24.dp)
@@ -131,6 +132,7 @@ fun QuickActionsDialog(
modifier = Modifier
.fillMaxWidth()
.clickable {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (isSelected) selected.remove(name) else selected.add(name)
},
shape = RoundedCornerShape(16.dp),
@@ -174,6 +176,7 @@ fun QuickActionsDialog(
Switch(
checked = isSelected,
onCheckedChange = { toggled ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (toggled) selected.add(name) else selected.remove(name)
},
colors = purrfectSwitchColors()
@@ -187,11 +190,17 @@ fun QuickActionsDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
- TextButton(onClick = onDismiss) {
+ TextButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onDismiss()
+ }) {
Text(translation["button.cancel"], color = PurrfectPalette.textSecondary)
}
Button(
- onClick = { onSave(selected.toList()) },
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onSave(selected.toList())
+ },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
contentColor = Color.White
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt
index d4149e8b..bf1b1508 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ScriptingRootSection.kt
@@ -282,6 +282,7 @@ class ScriptingRootSection : Routes.Route() {
}
var openSettings by remember(script) { mutableStateOf(false) }
var openActions by remember { mutableStateOf(false) }
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val dispatcher = rememberAsyncUpdateDispatcher()
val reloadCallback = remember { suspend { dispatcher.dispatch() } }
@@ -314,7 +315,12 @@ class ScriptingRootSection : Routes.Route() {
Column(
modifier = Modifier
.fillMaxWidth()
- .clickable(enabled = enabled) { if (enabled) openSettings = !openSettings }
+ .clickable(enabled = enabled) {
+ if (enabled) {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ openSettings = !openSettings
+ }
+ }
.background(PurrfectPalette.cardOverlay, cardShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
@@ -391,12 +397,16 @@ class ScriptingRootSection : Routes.Route() {
}
}
}
- IconButton(onClick = { openActions = !openActions }) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ openActions = !openActions
+ }) {
Icon(Icons.Default.Build, translation["actions_button"], tint = Color.White)
}
Switch(
checked = enabled,
onCheckedChange = { isChecked ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
openSettings = false
context.coroutineScope.launch(Dispatchers.IO) {
runCatching {
@@ -438,6 +448,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Box(
modifier = Modifier
.fillMaxWidth()
@@ -473,7 +484,10 @@ class ScriptingRootSection : Routes.Route() {
)
)
)
- .clickable(onClick = onClick),
+ .clickable(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onClick()
+ }),
contentAlignment = Alignment.Center
) {
Icon(
@@ -858,6 +872,7 @@ class ScriptingRootSection : Routes.Route() {
val shrinkThreshold = 300f
val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Column(modifier = Modifier.headerHeightTracker(onPositioned), verticalArrangement = Arrangement.spacedBy(12.dp)) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
@@ -865,16 +880,28 @@ class ScriptingRootSection : Routes.Route() {
subtitle = if (selectedTab == 0) translation["installed_scripts_tab"] else translation["catalog_tab"],
scrollOffset = scrollOffset,
actions = {
- IconButton(onClick = onDocs) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onDocs()
+ }) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
- IconButton(onClick = onManageRepos) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onManageRepos()
+ }) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
}
- IconButton(onClick = onOpenFolder) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onOpenFolder()
+ }) {
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
}
- IconButton(onClick = onImport, enabled = folderSelected) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onImport()
+ }, enabled = folderSelected) {
Icon(Icons.Default.Link, contentDescription = translation["import_from_url_button"], tint = if (folderSelected) Color.White else Color.White.copy(alpha = 0.4f))
}
}
@@ -893,7 +920,10 @@ class ScriptingRootSection : Routes.Route() {
ScriptingTabSwitcher(
titles = titles,
selectedTab = selectedTab,
- onTabSelected = onTabSelected
+ onTabSelected = { index ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onTabSelected(index)
+ }
)
}
}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt
index 8b12ea67..bff8f04b 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt
@@ -364,29 +364,19 @@ class ManageScope: Routes.Route() {
private fun computeStreakETA(timestamp: Long): String? {
val now = System.currentTimeMillis()
- val stringBuilder = StringBuilder()
val diff = timestamp - now
val seconds = diff / 1000
val minutes = seconds / 60
val hours = minutes / 60
val days = hours / 24
- if (days > 0) {
- stringBuilder.append("$days day ")
- return stringBuilder.toString()
+
+ return when {
+ days > 0 -> translation.format(if (days == 1L) "eta_day" else "eta_days", "count" to days.toString())
+ hours > 0 -> translation.format(if (hours == 1L) "eta_hour" else "eta_hours", "count" to hours.toString())
+ minutes > 0 -> translation.format(if (minutes == 1L) "eta_minute" else "eta_minutes", "count" to minutes.toString())
+ seconds > 0 -> translation.format(if (seconds == 1L) "eta_second" else "eta_seconds", "count" to seconds.toString())
+ else -> null
}
- if (hours > 0) {
- stringBuilder.append("$hours hours ")
- return stringBuilder.toString()
- }
- if (minutes > 0) {
- stringBuilder.append("$minutes minutes ")
- return stringBuilder.toString()
- }
- if (seconds > 0) {
- stringBuilder.append("$seconds seconds ")
- return stringBuilder.toString()
- }
- return null
}
@OptIn(ExperimentalEncodingApi::class)
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt
index a21fb90f..612ca9b6 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt
@@ -127,6 +127,7 @@ class SocialRootSection : Routes.Route() {
override val floatingActionButton: @Composable () -> Unit = {
var addFriendDialog by remember { mutableStateOf(null as AddFriendDialog?) }
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
if (addFriendDialog != null) {
addFriendDialog?.Content {
@@ -141,6 +142,7 @@ class SocialRootSection : Routes.Route() {
FloatingActionButton(
onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
addFriendDialog = AddFriendDialog(
context,
AddFriendDialog.Actions(
@@ -339,6 +341,7 @@ class SocialRootSection : Routes.Route() {
onPreview: () -> Unit,
remainingHours: Int
) {
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
val cardGradient = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.22f),
@@ -351,7 +354,10 @@ class SocialRootSection : Routes.Route() {
.heightIn(min = 88.dp)
.border(1.dp, cardGradient, RoundedCornerShape(20.dp)),
shape = RoundedCornerShape(20.dp),
- onClick = onManage,
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onManage()
+ },
colors = CardDefaults.elevatedCardColors(
containerColor = Color.Transparent
)
@@ -461,7 +467,10 @@ class SocialRootSection : Routes.Route() {
}
Surface(
- onClick = onPreview,
+ onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onPreview()
+ },
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
@@ -507,6 +516,7 @@ class SocialRootSection : Routes.Route() {
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
+ val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
Column(
modifier = Modifier.headerHeightTracker(onPositioned),
@@ -519,7 +529,10 @@ class SocialRootSection : Routes.Route() {
actions = {
StatPill(label = translation["friends_tab"], value = friendCount)
StatPill(label = translation["groups_tab"], value = groupCount)
- IconButton(onClick = onSearchToggle) {
+ IconButton(onClick = {
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onSearchToggle()
+ }) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
@@ -542,7 +555,10 @@ class SocialRootSection : Routes.Route() {
SocialTabSwitcher(
titles = titles,
pagerState = pagerState,
- onTabSelected = onTabSelected
+ onTabSelected = { index ->
+ haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
+ onTabSelected(index)
+ }
)
}
}
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/theme/PurrfectPalette.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/theme/PurrfectPalette.kt
index 97e1c264..2bba7ced 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/theme/PurrfectPalette.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/theme/PurrfectPalette.kt
@@ -5,7 +5,7 @@ import androidx.compose.ui.graphics.Color
/**
* Centralized palette for the premium PurrfectSnap look.
- * Exactly aligned with the particle-box (Original Professional) standards.
+ * Avoids relying on MaterialTheme for tinting so we can keep a consistent brand glow everywhere.
*/
object PurrfectPalette {
val backgroundGradient = Brush.verticalGradient(
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt
index 9c6825c8..81b26900 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/screens/impl/SaveFolderScreen.kt
@@ -108,9 +108,14 @@ class SaveFolderScreen : SetupScreen() {
text = if (currentFolder.isBlank()) {
context.translation["setup.save_folder.system_default_label"]
} else {
- // Decode and simplify the URI for professional display
runCatching {
- Uri.decode(currentFolder).substringAfterLast("%3A", currentFolder).substringAfterLast(":")
+ val decoded = Uri.decode(currentFolder)
+ val friendlyPath = if (decoded.contains(":")) {
+ decoded.substringAfterLast(":")
+ } else {
+ decoded.substringAfterLast("/")
+ }
+ friendlyPath.trim('/').takeIf { it.isNotBlank() } ?: decoded
}.getOrDefault(currentFolder)
},
fontSize = 15.sp,
diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt
index d610ded9..22da0db8 100644
--- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt
+++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/Animations.kt
@@ -38,7 +38,7 @@ object Motion {
/**
* Standard scroll distance (in pixels) for the header to complete its morphing animation.
*/
- const val HEADER_MORPH_THRESHOLD = 250f
+ const val HEADER_MORPH_THRESHOLD = 300f
/**
* Milliseconds per pixel for marquee scrolling speed.
diff --git a/app/src/main/res/drawable/launcher_icon_monochrome.xml b/app/src/main/res/drawable/launcher_icon_monochrome.xml
deleted file mode 100644
index d98ea0b7..00000000
--- a/app/src/main/res/drawable/launcher_icon_monochrome.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
index d539a16b..de24c6e4 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
index 011ffc11..cc754261 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png
new file mode 100644
index 00000000..48adb330
Binary files /dev/null and b/app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png
new file mode 100644
index 00000000..011e8054
Binary files /dev/null and b/app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png
new file mode 100644
index 00000000..d6f62212
Binary files /dev/null and b/app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 00000000..893a6407
Binary files /dev/null and b/app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png
new file mode 100644
index 00000000..6b7db092
Binary files /dev/null and b/app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
index a51f1164..60d194b7 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png
index dfc99f66..8d3b55d7 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png
index 923e57bd..3b1d3c2b 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ
diff --git a/build.gradle.kts b/build.gradle.kts
index 27f10ae6..b130afb2 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -10,31 +10,26 @@ plugins {
// var versionName = "1.0.0"
// var versionCode = 210
-import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
abstract class GetVersionTask : DefaultTask() {
@get:Input
abstract val versionName: Property
- @get:OutputFile
- abstract val versionFile: RegularFileProperty
-
@TaskAction
fun writeVersion() {
- val file = versionFile.get().asFile
- file.parentFile.mkdirs()
- file.writeText(versionName.get())
+ val versionFile = project.layout.projectDirectory.file("app/build/version.txt").asFile
+ versionFile.parentFile.mkdirs()
+ versionFile.writeText(versionName.get())
}
}
tasks.register("getVersion") {
+ // Value comes from gradle.properties; falls back to 1.0.0 if not set.
versionName.set(providers.gradleProperty("APP_VERSION_NAME").orElse("1.0.0"))
- versionFile.set(project.layout.projectDirectory.file("app/build/version.txt"))
}
// You can still set these for legacy use by submodules or scripts:
diff --git a/common/src/main/assets/lang/bn.json b/common/src/main/assets/lang/bn.json
index 99e73e84..8626dae0 100644
--- a/common/src/main/assets/lang/bn.json
+++ b/common/src/main/assets/lang/bn.json
@@ -3237,7 +3237,7 @@
"username": "ইউজারনেম",
"user_id": "ইউজার আইডি",
"posted_on": "পোস্ট করা হয়েছে",
- "loading_username": "লোড হচ্ছে…",
+ "loading_username": "লোড হচ্ছে\u2026",
"username_copied": "ইউজারনেম কপি হয়েছে",
"user_id_copied": "ইউজার আইডি কপি হয়েছে",
"friend_status": "বন্ধু স্ট্যাটাস",
diff --git a/common/src/main/assets/lang/de_DE.json b/common/src/main/assets/lang/de_DE.json
index 67220c2e..1386078d 100644
--- a/common/src/main/assets/lang/de_DE.json
+++ b/common/src/main/assets/lang/de_DE.json
@@ -340,6 +340,7 @@
"remove_selected_tasks_title": "Möchtest du die ausgewählten Aufgaben wirklich entfernen?",
"remove_all_tasks_title": "Möchtest du wirklich alle Aufgaben entfernen?",
"delete_files_option": "Auch Dateien löschen",
+ "delete_files_option_hint": "Zugehörige Downloads dauerhaft entfernen",
"remove_selected_tasks_confirm": "{count} Aufgaben entfernen?",
"remove_all_tasks_confirm": "Alle Aufgaben entfernen?"
},
diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json
index 70e8aaca..17d2ed2f 100644
--- a/common/src/main/assets/lang/en_UK.json
+++ b/common/src/main/assets/lang/en_UK.json
@@ -3236,7 +3236,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
- "loading_username": "Loading…",
+ "loading_username": "Loading\u2026",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json
index 2f8eea71..09678375 100644
--- a/common/src/main/assets/lang/en_US.json
+++ b/common/src/main/assets/lang/en_US.json
@@ -1,4 +1,4 @@
-{
+{
"setup": {
"activity": {
"wrong_apk_title": "Wrong APK installed",
@@ -252,7 +252,7 @@
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
- "about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
+ "about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
"github_button": "GitHub",
"telegram_button": "Telegram"
},
@@ -343,6 +343,7 @@
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"delete_files_option": "Also delete files",
+ "delete_files_option_hint": "Permanently remove associated downloads",
"remove_selected_tasks_confirm": "Remove {count} tasks?",
"remove_all_tasks_confirm": "Remove all tasks?"
},
@@ -351,6 +352,8 @@
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
+ "include_saved_locations": "Include Saved Locations",
+ "include_saved_locations_description": "Export your saved location coordinates",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
@@ -413,7 +416,15 @@
"streaks_expiration_text_expired": "Expired",
"reminder_button": "Set Reminder",
"delete_scope_confirm_dialog_title": "Are you sure you want to delete a {scope}?",
- "notes_placeholder": "Click to add a note"
+ "notes_placeholder": "Click to add a note",
+ "eta_day": "{count} day",
+ "eta_days": "{count} days",
+ "eta_hour": "{count} hour",
+ "eta_hours": "{count} hours",
+ "eta_minute": "{count} minute",
+ "eta_minutes": "{count} minutes",
+ "eta_second": "{count} second",
+ "eta_seconds": "{count} seconds"
},
"logged_stories": {
"story_failed_to_load": "Failed to load",
@@ -3239,7 +3250,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
- "loading_username": "Loading…",
+ "loading_username": "Loading\u2026",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
@@ -3643,3 +3654,4 @@
}
}
+
diff --git a/common/src/main/assets/lang/es_ES.json b/common/src/main/assets/lang/es_ES.json
index 76c0bb02..efcab5bc 100644
--- a/common/src/main/assets/lang/es_ES.json
+++ b/common/src/main/assets/lang/es_ES.json
@@ -3236,7 +3236,7 @@
"username": "Nombre de usuario",
"user_id": "ID de Usuario",
"posted_on": "Publicado",
- "loading_username": "Cargando…",
+ "loading_username": "Cargando\u2026",
"username_copied": "Nombre de usuario copiado",
"user_id_copied": "ID de usuario copiado",
"friend_status": "Estado de amigo",
diff --git a/common/src/main/assets/lang/hu_HU.json b/common/src/main/assets/lang/hu_HU.json
index eebe9377..14b3fc65 100644
--- a/common/src/main/assets/lang/hu_HU.json
+++ b/common/src/main/assets/lang/hu_HU.json
@@ -3236,7 +3236,7 @@
"username": "Felhasználónév",
"user_id": "Felhasználói azonosító",
"posted_on": "Közzétéve",
- "loading_username": "Betöltés…",
+ "loading_username": "Betöltés\u2026",
"username_copied": "Felhasználónév másolva",
"user_id_copied": "Felhasználói azonosító másolva",
"friend_status": "Barát státusz",
diff --git a/common/src/main/assets/lang/it_IT.json b/common/src/main/assets/lang/it_IT.json
index 260abaf6..5a18dbb5 100644
--- a/common/src/main/assets/lang/it_IT.json
+++ b/common/src/main/assets/lang/it_IT.json
@@ -3236,7 +3236,7 @@
"username": "Username",
"user_id": "ID Utente",
"posted_on": "Pubblicato",
- "loading_username": "Caricamento…",
+ "loading_username": "Caricamento\u2026",
"username_copied": "Username copiato",
"user_id_copied": "ID Utente copiato",
"friend_status": "Stato amicizia",
diff --git a/common/src/main/assets/lang/ja_JP.json b/common/src/main/assets/lang/ja_JP.json
index 8d0917e0..1a43befe 100644
--- a/common/src/main/assets/lang/ja_JP.json
+++ b/common/src/main/assets/lang/ja_JP.json
@@ -3236,7 +3236,7 @@
"username": "ユーザー名",
"user_id": "ユーザーID",
"posted_on": "投稿日",
- "loading_username": "読み込み中…",
+ "loading_username": "読み込み中\u2026",
"username_copied": "ユーザー名をコピーしました",
"user_id_copied": "ユーザーIDをコピーしました",
"friend_status": "フレンドステータス",
diff --git a/common/src/main/assets/lang/ko_KR.json b/common/src/main/assets/lang/ko_KR.json
index 7fdcc236..89c50d6c 100644
--- a/common/src/main/assets/lang/ko_KR.json
+++ b/common/src/main/assets/lang/ko_KR.json
@@ -3237,7 +3237,7 @@
"username": "사용자 이름",
"user_id": "사용자 ID",
"posted_on": "게시일",
- "loading_username": "로드 중…",
+ "loading_username": "로드 중\u2026",
"username_copied": "사용자 이름 복사됨",
"user_id_copied": "사용자 ID 복사됨",
"friend_status": "친구 상태",
diff --git a/common/src/main/assets/lang/nl.json b/common/src/main/assets/lang/nl.json
index 0cee8ab2..f024c606 100644
--- a/common/src/main/assets/lang/nl.json
+++ b/common/src/main/assets/lang/nl.json
@@ -3236,7 +3236,7 @@
"username": "Gebruikersnaam",
"user_id": "Gebruikers-ID",
"posted_on": "Geplaatst",
- "loading_username": "Laden…",
+ "loading_username": "Laden\u2026",
"username_copied": "Gebruikersnaam gekopieerd",
"user_id_copied": "Gebruikers-ID gekopieerd",
"friend_status": "Vriendstatus",