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') - 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",