Implemented high-precision Vector Themed Icons and optimized system Updater logic, and improved the haptics manager wide.
107
.github/workflows/debug.yml
vendored
@@ -1,29 +1,11 @@
|
||||
name: PurrfectSnap Debug CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dark-knight
|
||||
paths:
|
||||
- 'app/**'
|
||||
- 'core/**'
|
||||
- 'common/**'
|
||||
- 'native/**'
|
||||
- 'valdi/**'
|
||||
- 'mapper/**'
|
||||
- 'build.gradle.kts'
|
||||
- 'gradle.properties'
|
||||
- 'gradle/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ci_upload:
|
||||
description: 'Upload to CI channel'
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
job_armv8:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -116,8 +98,7 @@ jobs:
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-$SHORT_SHA.apk
|
||||
mv app/build/outputs/apk/armv8/debug/purrfectsnap-armv8Debug-signed.apk app/build/outputs/apk/armv8/debug/purrfectsnap-${{ env.version }}-armv8-${GITHUB_SHA::7}.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -215,94 +196,10 @@ jobs:
|
||||
- name: Delete unsigned APK file and rename the signed one
|
||||
run: |
|
||||
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-$SHORT_SHA.apk
|
||||
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
|
||||
- name: Upload artifact
|
||||
id: upload_apk
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: purrfectsnap-armv7-debug
|
||||
path: app/build/outputs/apk/armv7/debug/*.apk
|
||||
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [job_armv8, job_armv7]
|
||||
if: always() && github.ref == 'refs/heads/dark-knight'
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: all-apks
|
||||
merge-multiple: true
|
||||
- name: Dispatch Modernized Build Card to Telegram
|
||||
env:
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_TO }}
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y bc jq
|
||||
./gradlew --configuration-cache getVersion || true
|
||||
VERSION_NAME=$(cat app/build/version.txt 2>/dev/null || echo "Unknown")
|
||||
|
||||
if [[ "${{ needs.job_armv8.result }}" == "success" && "${{ needs.job_armv7.result }}" == "success" ]]; then
|
||||
BUILD_STATUS="✅ SUCCESS"
|
||||
else
|
||||
BUILD_STATUS="❌ FAILED"
|
||||
fi
|
||||
|
||||
# --- Premium Logic Port ---
|
||||
GIT_HASH=$(git rev-parse --short HEAD)
|
||||
AUTHOR=$(git log -1 --pretty=%an)
|
||||
BRANCH_NAME="${{ github.ref_name }}"
|
||||
|
||||
if [[ "$BRANCH_NAME" == "main" ]]; then
|
||||
BUILD_TYPE="💎 STABLE RELEASE"
|
||||
else
|
||||
BUILD_TYPE="🧪 PRE-RELEASE (BETA)"
|
||||
fi
|
||||
|
||||
CHANGELOG=$(git log -n 5 --pretty=format:"• %s" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
||||
ACTION_LINK="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
MESSAGE="<b>📱 PurrfectSnap | $BRANCH_NAME</b>\n━━━━━━━━━━━━━━━━\n📌 <b>Status:</b> $BUILD_STATUS\n🧪 <b>Type:</b> <code>$BUILD_TYPE</code>\n🆔 <b>Build:</b> <code>#$GIT_HASH</code>\n👤 <b>Author:</b> $AUTHOR\n━━━━━━━━━━━━━━━━\n📝 <b>Latest Commits:</b>\n$CHANGELOG\n━━━━━━━━━━━━━━━━\n📂 <b>Assets & Verification:</b>"
|
||||
|
||||
# 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📦 <b>$ARCH</b> ($FILESIZE_MB MB): <a href=\"$MIRROR_URL\">Download</a>\n<code>SHA256: $SHA256_SHORT</code>"
|
||||
|
||||
# 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🛠 <a href=\"$ACTION_LINK\">View GitHub Action</a>"
|
||||
|
||||
# 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"
|
||||
|
||||
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ object Updater {
|
||||
val versionName: String,
|
||||
val releaseUrl: String,
|
||||
val workflowId: Long?,
|
||||
val body: String? = null,
|
||||
val assetDownloads: Map<String, String> = 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<Channel, Pair<Long, LatestRelease?>>()
|
||||
private val cache = java.util.concurrent.ConcurrentHashMap<Channel, Pair<Long, Result<LatestRelease?>>>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, String?>() }
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String, Boolean>() }
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="1000"
|
||||
android:viewportHeight="1000">
|
||||
|
||||
<group
|
||||
android:scaleX="0.65"
|
||||
android:scaleY="0.65"
|
||||
android:translateX="175"
|
||||
android:translateY="175">
|
||||
|
||||
<path
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="m397.9,491.5h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6h-55.1c-10.1,0 -18.4,8.2 -18.4,18.4h0c0,10.1 8.2,18.4 18.4,18.4h55.1c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3Z"/>
|
||||
<path
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="m814.2,491.5h-62.6v-49.7h-0.1c0,-2 0.1,-4.1 0.1,-6.1 0,-152.1 -123.3,-275.5 -275.5,-275.5s-275.5,123.3 -275.5,275.5c0,2 0,4.1 0.1,6.1h-0.1v302.1c0,29.6 13.5,57.6 36.7,76l11.9,9.4c18.3,13.9 47,13.8 65.2,0l18.5,-14c7.1,-5.4 21,-5.4 28.1,0l18.5,14c18.3,13.9 46.9,13.9 65.2,0l18.5,-14c3.3,-2.5 8.2,-3.8 13.1,-4 4.9,0.2 9.7,1.5 13,4l18.5,14c18.3,13.8 46.8,13.8 65.1,0l18.5,-14c7.1,-5.4 20.9,-5.4 28,0l18.5,14c18.2,13.8 46.8,13.8 65.1,0l11.9,-9.4c23.2,-18.4 36.7,-46.4 36.7,-75.9v-117.8h62.6c33.8,0 61.2,-30.1 61.2,-67.3s-27.4,-67.3 -61.2,-67.3ZM714.8,441.9v310.2c0,16.4 -7.7,31.9 -20.7,41.8l-9.6,7.3c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-18.2,-13.8 -46.8,-13.8 -65.1,0l-18.5,14c-7.1,5.4 -20.9,5.4 -28,0l-18.5,-14c-8.8,-6.7 -20.1,-10.1 -31.4,-10.3v-0c-0.1,0 -0.1,0 -0.2,0 -0,0 -0.1,0 -0.1,0h0c-11.4,0.2 -22.6,3.7 -31.5,10.4l-18.5,14c-7.1,5.4 -21,5.4 -28.1,0l-18.5,-14c-18.3,-13.8 -46.9,-13.8 -65.2,0l-18.5,14c-7.1,5.4 -21,5.4 -28,0l-9.7,-7.4c-13.1,-9.9 -20.8,-25.4 -20.8,-41.8v-310.1h0.1c-0.1,-2 -0.1,-4.1 -0.1,-6.1 0,-131.9 106.9,-238.7 238.7,-238.7s238.7,106.9 238.7,238.7c0,2 -0,4.1 -0.1,6.1h0.1ZM814.2,589.5h-62.6v-61.2h62.6c15.3,0 27.8,13.7 27.8,30.6s-12.5,30.6 -27.8,30.6Z"
|
||||
tools:ignore="VectorPath" />
|
||||
<path
|
||||
android:fillColor="#ffffffff"
|
||||
android:pathData="m711.1,336.1c-6.6,-1.6 -10.6,-1.7 -17.4,-2.8 -33.1,-5.4 -65.4,-0.8 -97.3,8.2 -7,2 -13.9,3.4 -20.9,3.5 -7,-0.2 -13.9,-1.5 -20.9,-3.5 -31.9,-9 -64.2,-13.6 -97.3,-8.2 -6.8,1.1 -10.8,1.1 -17.4,2.8 -6,1.5 -7.5,11.2 -5.5,29.1 0.9,8.2 3.3,16.1 4.9,24.2 1.6,8.1 3.6,16.1 7.9,23.3 4.1,6.8 10.1,11.6 17.5,13.9 16.9,5.4 34.1,6.2 51.3,1.4 14.5,-4.1 26.1,-12.6 33.4,-25.9 5.4,-9.9 9.7,-20.4 14.5,-30.7 0.7,-1.6 1.3,-3.2 2.1,-4.8 2.2,-4.4 5,-6.3 9.6,-6.3 4.5,-0 7.3,1.8 9.6,6.3 0.8,1.6 1.4,3.2 2.1,4.8 4.8,10.3 9.1,20.8 14.5,30.7 7.2,13.4 18.9,21.9 33.4,25.9 17.1,4.8 34.4,4 51.3,-1.4 7.4,-2.3 13.4,-7.1 17.5,-13.9 4.3,-7.2 6.3,-15.1 7.9,-23.3 1.5,-8.1 4,-16 4.9,-24.2 2,-17.9 0.5,-27.6 -5.5,-29.1Z"/>
|
||||
|
||||
</group>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-night-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
app/src/main/res/mipmap-night-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/src/main/res/mipmap-night-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
app/src/main/res/mipmap-night-xxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
app/src/main/res/mipmap-night-xxxhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 27 KiB |
@@ -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<String>
|
||||
|
||||
@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<GetVersionTask>("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:
|
||||
|
||||
@@ -3237,7 +3237,7 @@
|
||||
"username": "ইউজারনেম",
|
||||
"user_id": "ইউজার আইডি",
|
||||
"posted_on": "পোস্ট করা হয়েছে",
|
||||
"loading_username": "লোড হচ্ছে…",
|
||||
"loading_username": "লোড হচ্ছে\u2026",
|
||||
"username_copied": "ইউজারনেম কপি হয়েছে",
|
||||
"user_id_copied": "ইউজার আইডি কপি হয়েছে",
|
||||
"friend_status": "বন্ধু স্ট্যাটাস",
|
||||
|
||||
@@ -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?"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
|
||||
"about_thanks_title": "With love, PurrfectSnap Team",
|
||||
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
|
||||
"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 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3236,7 +3236,7 @@
|
||||
"username": "ユーザー名",
|
||||
"user_id": "ユーザーID",
|
||||
"posted_on": "投稿日",
|
||||
"loading_username": "読み込み中…",
|
||||
"loading_username": "読み込み中\u2026",
|
||||
"username_copied": "ユーザー名をコピーしました",
|
||||
"user_id_copied": "ユーザーIDをコピーしました",
|
||||
"friend_status": "フレンドステータス",
|
||||
|
||||
@@ -3237,7 +3237,7 @@
|
||||
"username": "사용자 이름",
|
||||
"user_id": "사용자 ID",
|
||||
"posted_on": "게시일",
|
||||
"loading_username": "로드 중…",
|
||||
"loading_username": "로드 중\u2026",
|
||||
"username_copied": "사용자 이름 복사됨",
|
||||
"user_id_copied": "사용자 ID 복사됨",
|
||||
"friend_status": "친구 상태",
|
||||
|
||||
@@ -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",
|
||||
|
||||