Standardized sub-page layouts and enhanced system stability across all modules

This commit is contained in:
DarkKnight2122
2026-02-23 21:46:42 +05:30
parent 7623ec30c9
commit f8e5d7370a
35 changed files with 2566 additions and 3521 deletions

View File

@@ -1,11 +1,29 @@
name: PurrfectSnap Debug CI name: PurrfectSnap Debug CI
on: on:
push:
branches:
- dark-knight
paths:
- 'app/**'
- 'core/**'
- 'common/**'
- 'native/**'
- 'valdi/**'
- 'mapper/**'
- 'build.gradle.kts'
- 'gradle.properties'
- 'gradle/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
ci_upload: ci_upload:
description: 'Upload to CI channel' description: 'Upload to CI channel'
required: false required: false
type: boolean type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
job_armv8: job_armv8:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -98,7 +116,8 @@ jobs:
- name: Delete unsigned APK file and rename the signed one - name: Delete unsigned APK file and rename the signed one
run: | run: |
find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete find app/build/outputs/apk/armv8/debug/ -type f ! -name '*-signed*' -delete
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 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
- name: Upload artifact - name: Upload artifact
id: upload_apk id: upload_apk
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -196,10 +215,94 @@ jobs:
- name: Delete unsigned APK file and rename the signed one - name: Delete unsigned APK file and rename the signed one
run: | run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk 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
- name: Upload artifact - name: Upload artifact
id: upload_apk id: upload_apk
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: purrfectsnap-armv7-debug name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk 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/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/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
View File

@@ -18,3 +18,7 @@ cloudflare/**/allowed_codes.local.*
security/allowed-codes.local.* security/allowed-codes.local.*
security/allowed_codes.local.* security/allowed_codes.local.*
valdi/node_modules/ valdi/node_modules/
ROADMAP.md
.github/workflows/upload_telegram.sh
android9fixes.md
release.keystore/

View File

@@ -132,7 +132,7 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) { if (shouldShowAbiWarning) {
AestheticDialog( AestheticDialog(
onDismissRequest = {}, onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"], title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "", text = "",
icon = Icons.Filled.Warning, icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"], confirmButtonText = managerContext.translation["common.close"],
@@ -140,11 +140,20 @@ class MainActivity : ComponentActivity() {
showCloseButton = false, showCloseButton = false,
opaque = true, opaque = true,
customContent = { customContent = {
Text( Column(
text = managerContext.translation["wrong_apk_message"], modifier = Modifier.fillMaxWidth(),
color = PurrfectPalette.textSecondary, horizontalAlignment = Alignment.CenterHorizontally,
lineHeight = 18.sp verticalArrangement = Arrangement.spacedBy(12.dp)
) ) {
Text(
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 20.sp,
fontSize = 15.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
} }
) )
} }
@@ -163,7 +172,7 @@ class MainActivity : ComponentActivity() {
insetsController.isAppearanceLightNavigationBars = isLight insetsController.isAppearanceLightNavigationBars = isLight
} }
// Floating bottom bar height and vertical spacing so floating action buttons and scrolling content remain readable: // Floating bottom bar height and vertical spacing so floating action buttons and scrolling content remain readable:
val bottomPadding = 80.dp + 16.dp + val bottomPadding = 82.dp + 4.dp +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
routes.bottomPadding = bottomPadding routes.bottomPadding = bottomPadding
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()

View File

@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -72,6 +73,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -122,22 +124,36 @@ class Navigation(
) { ) {
private val translation by lazy { context.translation.getCategory("manager.navigation") } private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false) var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
@Composable @Composable
fun TopBar() { fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) } val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
if (currentRoute?.routeInfo?.hasOwnTopBar == true) return if (currentRoute?.routeInfo?.hasOwnTopBar == true) return
val shrinkThreshold = 300f
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val headerHeight = lerp(64.dp, 48.dp, focusFactor)
val canGoBack = remember(navBackStackEntry) { val canGoBack = remember(navBackStackEntry) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
} }
TopAppBar( TopAppBar(
modifier = Modifier.height(headerHeight),
title = { title = {
currentRoute?.apply { currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let { title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text( Text(
text = it, text = it,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer {
// Title stays visible but scales slightly down
scaleX = 1f - (focusFactor * 0.05f)
scaleY = 1f - (focusFactor * 0.05f)
translationY = (-2 * focusFactor).dp.toPx()
}
) )
} }
} }
@@ -146,7 +162,11 @@ class Navigation(
val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton") val backButtonAnimation by animateFloatAsState(if (canGoBack) 1f else 0f, label = "backButton")
Box( Box(
modifier = Modifier modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation } .graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.width(lerp(0.dp, 48.dp, backButtonAnimation)) .width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp) .height(48.dp)
) { ) {
@@ -177,6 +197,13 @@ class Navigation(
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker) listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
} }
val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } } val availableRouteMap = remember(availableRoutes) { availableRoutes.associateBy { it.routeInfo.id } }
val shrinkThreshold = 300f
val focusFactor = (globalScrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val barHeight = lerp(82.dp, 64.dp, focusFactor)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val iconTranslationY = (10 * focusFactor).dp // Sinks towards vertical center
val prefs = remember { context.sharedPreferences } val prefs = remember { context.sharedPreferences }
val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") } val defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> { fun loadSelected(): List<String> {
@@ -233,11 +260,20 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth") val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface( Surface(
shape = barShape, shape = barShape,
color = Color.Transparent, color = Color.White.copy(alpha = 0.08f), // Restored frosted glass harmony
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
modifier = Modifier modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth()) .then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.height(barHeight)
.drawBehind { .drawBehind {
val radius = size.width * 0.62f val radius = size.width * 0.62f
drawCircle( drawCircle(
@@ -263,7 +299,7 @@ class Navigation(
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(82.dp) .height(barHeight)
.clip(barShape) .clip(barShape)
.background(PurrfectPalette.cardOverlay) .background(PurrfectPalette.cardOverlay)
.border(BorderStroke(1.dp, barBorder), barShape) .border(BorderStroke(1.dp, barBorder), barShape)
@@ -296,7 +332,7 @@ class Navigation(
) )
} }
) )
Box(Modifier.fillMaxWidth().height(82.dp)) { Box(Modifier.fillMaxWidth().height(barHeight)) {
var barWidthPx by remember { mutableStateOf(0f) } var barWidthPx by remember { mutableStateOf(0f) }
val itemCount = selectedRoutes.size.coerceAtLeast(1) val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current val density = androidx.compose.ui.platform.LocalDensity.current
@@ -369,7 +405,7 @@ class Navigation(
.fillMaxHeight() .fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp)) .width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX) .offset(x = offsetX)
.padding(vertical = 10.dp, horizontal = 2.dp) .padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim } .graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) { ) {
Box( Box(
@@ -430,7 +466,10 @@ class Navigation(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(22.dp + 2.dp * selectionProgress) .size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress } .graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
) )
}, },
label = { label = {
@@ -441,11 +480,15 @@ class Navigation(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress), color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
maxLines = if (isLong) 2 else 1, maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip, overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong, softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally) modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx() // Fall into icon
}
) )
}, },
selected = isSelected, selected = isSelected,

View File

@@ -87,12 +87,12 @@ object Updater {
}.getOrNull() }.getOrNull()
private fun fetchLatestDebugCI() = runCatching { private fun fetchLatestDebugCI() = runCatching {
val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?event=workflow_dispatch&branch=dev").build()).execute().use { val actionRuns = OkHttpClient().newCall(Request.Builder().url("https://api.github.com/repos/particle-box/PurrfectSnap/actions/runs?branch=dev&status=success").build()).execute().use {
if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}") if (!it.isSuccessful) throw Throwable("Failed to fetch CI runs: ${it.code}")
JsonParser.parseString(it.body?.string()).asJsonObject JsonParser.parseString(it.body?.string()).asJsonObject
} }
val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run -> val debugRuns = actionRuns.getAsJsonArray("workflow_runs")?.mapNotNull { it.asJsonObject }?.filter { run ->
run.get("conclusion")?.takeIf { it.isJsonPrimitive }?.asString == "success" && run.getAsJsonPrimitive("path")?.asString == ".github/workflows/debug.yml" run.getAsJsonPrimitive("name")?.asString == "PurrfectSnap Debug CI"
} ?: throw Throwable("No debug CI runs found") } ?: throw Throwable("No debug CI runs found")
val latestRun = debugRuns.firstOrNull() ?: throw Throwable("No debug CI runs found") val latestRun = debugRuns.firstOrNull() ?: throw Throwable("No debug CI runs found")
@@ -109,15 +109,22 @@ object Updater {
AbstractLogger.directError("Failed to fetch latest debug CI", it) AbstractLogger.directError("Failed to fetch latest debug CI", it)
}.getOrNull() }.getOrNull()
private val cache = mutableMapOf<Channel, LatestRelease?>() private val cache = mutableMapOf<Channel, Pair<Long, LatestRelease?>>()
fun getLatestRelease(channel: Channel): LatestRelease? { fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) { val cached = cache[channel]
if (BuildConfig.DEBUG) { // Use 24-hour TTL (Time To Live) for cache to optimize API calls
fetchLatestDebugCI() ?: fetchLatestRelease(channel) if (cached != null && (System.currentTimeMillis() - cached.first) < 24 * 60 * 60 * 1000) {
} else { return cached.second
fetchLatestRelease(channel)
}
} }
val result = if (channel == Channel.PRERELEASE) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
cache[channel] = System.currentTimeMillis() to result
return result
} }
} }

View File

@@ -4,8 +4,8 @@ import android.net.Uri
import android.text.format.Formatter import android.text.format.Formatter
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -38,7 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -157,8 +157,8 @@ class FileImportsRoot: Routes.Route() {
.padding(horizontal = 10.dp), .padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = topBarHeight + 8.dp, top = topBarHeight,
bottom = routes.bottomPadding + 16.dp bottom = routes.bottomPadding
) )
) { ) {
item { item {
@@ -270,9 +270,7 @@ class FileImportsRoot: Routes.Route() {
title = titleText ?: translation["import_file_button"], title = titleText ?: translation["import_file_button"],
subtitle = null, subtitle = null,
onBack = { routes.navController.popBackStack() }, onBack = { routes.navController.popBackStack() },
modifier = Modifier.onGloballyPositioned { modifier = Modifier.headerHeightTracker { topBarHeight = it }
topBarHeight = with(density) { it.size.height.toDp() }
}
) )
} }
} }

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -38,6 +39,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -289,64 +292,137 @@ class LoggerHistoryRoot : Routes.Route() {
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
Column(modifier = Modifier.fillMaxSize()) { val listState = rememberLazyListState()
FloatingTopBar( val density = androidx.compose.ui.platform.LocalDensity.current
title = context.translation["manager.routes.logger_history"] ?: "Logger History", var controlsHeight by remember { mutableStateOf(100.dp) }
onBack = { routes.navController.popBackStack() }
)
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) { LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
var expanded by remember { mutableStateOf(false) } val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Surface( Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
modifier = Modifier Spacer(modifier = Modifier.height(controlsHeight))
.fillMaxWidth()
.padding(top = 4.dp, bottom = 10.dp), var expanded by remember { mutableStateOf(false) }
shape = RoundedCornerShape(22.dp),
color = PurrfectPalette.cardOverlayColor, Surface(
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
modifier = Modifier modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp)) .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp), .padding(top = 4.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) shape = RoundedCornerShape(22.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) { ) {
ExposedDropdownMenuBox( Column(
expanded = expanded, modifier = Modifier
onExpandedChange = { expanded = it }, .background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? { ExposedDropdownMenuBox(
if (conversationInfo == null) return null expanded = expanded,
onExpandedChange = { expanded = it },
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
return conversationInfo.groupTitle?.let { return conversationInfo.groupTitle?.let {
translation.format("list_group_format", "name" to it) translation.format("list_group_format", "name" to it)
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let { } ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")")) translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
} ?: context.database.findFriend(conversationInfo.conversationId)?.let { } ?: context.database.findFriend(conversationInfo.conversationId)?.let {
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")") translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
} ?: conversationInfo.usernames.firstOrNull()?.let { } ?: conversationInfo.usernames.firstOrNull()?.let {
translation.format("list_friend_format", "name" to "($it)") translation.format("list_friend_format", "name" to "($it)")
}
} }
}
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) { val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let { selectedConversation?.let {
conversationInfoCache.getOrPut(it) { conversationInfoCache.getOrPut(it) {
formatConversationInfo(loggerWrapper.getConversationInfo(it)) formatConversationInfo(loggerWrapper.getConversationInfo(it))
}
}
}
OutlinedTextField(
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
onValueChange = {},
readOnly = true,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = PurrfectPalette.glowSecondary
)
)
val conversations by rememberAsyncMutableState(defaultValue = emptyList<String>()) {
loggerWrapper.getAllConversations().toMutableList()
}
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
conversations.forEach { conversationId ->
DropdownMenuItem(onClick = {
selectedConversation = conversationId
expanded = false
}, text = {
val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) {
conversationInfoCache.getOrPut(conversationId) {
formatConversationInfo(loggerWrapper.getConversationInfo(conversationId))
}
}
Text(
text = remember(conversationInfo) { conversationInfo ?: conversationId },
fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal,
color = Color.White,
overflow = TextOverflow.Ellipsis
)
})
} }
} }
} }
OutlinedTextField( OutlinedTextField(
value = selectedConversationInfo ?: translation["select_conversation_placeholder"], value = stringFilter,
onValueChange = {}, onValueChange = { stringFilter = it },
readOnly = true, singleLine = true,
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.menuAnchor(MenuAnchorType.PrimaryNotEditable) shape = RoundedCornerShape(16.dp),
.fillMaxWidth(), placeholder = {
Text(
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search",
color = PurrfectPalette.textSecondary
)
},
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
tint = PurrfectPalette.textSecondary
)
},
trailingIcon = if (stringFilter.isNotBlank()) {
{
IconButton(onClick = { stringFilter = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = translation["close_button_description"],
tint = PurrfectPalette.textSecondary
)
}
}
} else null,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent,
@@ -358,160 +434,99 @@ class LoggerHistoryRoot : Routes.Route() {
) )
) )
val conversations by rememberAsyncMutableState(defaultValue = emptyList<String>()) { Row(
loggerWrapper.getAllConversations().toMutableList() modifier = Modifier.fillMaxWidth(),
} horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { ) {
conversations.forEach { conversationId -> Text(
DropdownMenuItem(onClick = { translation["reverse_order_checkbox"],
selectedConversation = conversationId color = PurrfectPalette.textSecondary,
expanded = false fontSize = 13.sp
}, text = { )
val conversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(conversationId)) { Checkbox(
conversationInfoCache.getOrPut(conversationId) { checked = reverseOrder,
formatConversationInfo(loggerWrapper.getConversationInfo(conversationId)) onCheckedChange = { reverseOrder = it },
} colors = CheckboxDefaults.colors(
} checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
Text( uncheckedColor = Color.White.copy(alpha = 0.35f)
text = remember(conversationInfo) { conversationInfo ?: conversationId }, )
fontWeight = if (conversationId == selectedConversation) FontWeight.Bold else FontWeight.Normal, )
color = Color.White,
overflow = TextOverflow.Ellipsis
)
})
}
} }
} }
}
OutlinedTextField( var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
value = stringFilter, var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
onValueChange = { stringFilter = it }, val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
singleLine = true,
modifier = Modifier.fillMaxWidth(), LazyColumn(
shape = RoundedCornerShape(16.dp), state = listState,
placeholder = { contentPadding = PaddingValues(bottom = routes.bottomPadding)
Text( ) {
text = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search", items(messages) { message ->
color = PurrfectPalette.textSecondary MessageView(message)
) }
}, item {
leadingIcon = { if (selectedConversation != null) {
Icon( if (hasReachedEnd) {
imageVector = Icons.Filled.Search, Text(translation["no_more_messages"], modifier = Modifier
contentDescription = null, .padding(8.dp)
tint = PurrfectPalette.textSecondary .fillMaxWidth(), textAlign = TextAlign.Center, color = Color.White)
) } else {
}, Row(
trailingIcon = if (stringFilter.isNotBlank()) { horizontalArrangement = Arrangement.Center,
{ modifier = Modifier.fillMaxWidth()
IconButton(onClick = { stringFilter = "" }) { ) {
Icon( CircularProgressIndicator(
imageVector = Icons.Filled.Close, modifier = Modifier
contentDescription = translation["close_button_description"], .height(20.dp)
tint = PurrfectPalette.textSecondary .padding(8.dp),
color = PurrfectPalette.glowSecondary,
strokeWidth = 2.dp
) )
} }
} }
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = PurrfectPalette.glowSecondary
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
translation["reverse_order_checkbox"],
color = PurrfectPalette.textSecondary,
fontSize = 13.sp
)
Checkbox(
checked = reverseOrder,
onCheckedChange = { reverseOrder = it },
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
checkmarkColor = Color.White,
uncheckedColor = Color.White.copy(alpha = 0.35f)
)
)
}
}
}
var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) }
var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) }
val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() }
LazyColumn(
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
items(messages) { message ->
MessageView(message)
}
item {
if (selectedConversation != null) {
if (hasReachedEnd) {
Text(translation["no_more_messages"], modifier = Modifier
.padding(8.dp)
.fillMaxWidth(), textAlign = TextAlign.Center)
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier
.height(20.dp)
.padding(8.dp),
color = PurrfectPalette.glowSecondary,
strokeWidth = 2.dp
)
}
} }
} LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { val newMessages = loggerWrapper.fetchMessages(
val newMessages = loggerWrapper.fetchMessages( selectedConversation ?: return@withContext,
selectedConversation ?: return@withContext, lastFetchMessageTimestamp,
lastFetchMessageTimestamp, 30,
30, reverseOrder
reverseOrder ) { messageData ->
) { messageData -> if (stringFilter.isEmpty()) return@fetchMessages true
if (stringFilter.isEmpty()) return@fetchMessages true var isMatch = false
var isMatch = false decodeMessage(messageData) { contentType, messageReader, _ ->
decodeMessage(messageData) { contentType, messageReader, _ -> if (contentType == ContentType.CHAT) {
if (contentType == ContentType.CHAT) { val content = messageReader.getString(2, 1) ?: return@decodeMessage
val content = messageReader.getString(2, 1) ?: return@decodeMessage isMatch = content.contains(stringFilter, ignoreCase = true)
isMatch = content.contains(stringFilter, ignoreCase = true) }
} }
isMatch
}
if (newMessages.isEmpty()) {
hasReachedEnd = true
return@withContext
}
lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext
withContext(Dispatchers.Main) {
messages.addAll(newMessages)
} }
isMatch
}
if (newMessages.isEmpty()) {
hasReachedEnd = true
return@withContext
}
lastFetchMessageTimestamp = newMessages.lastOrNull()?.sendTimestamp ?: return@withContext
withContext(Dispatchers.Main) {
messages.addAll(newMessages)
} }
} }
} }
} }
} }
}
} FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
} }
} }
} }

View File

@@ -1,18 +1,29 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package me.eternal.purrfectsnap.ui.manager.pages package me.eternal.purrfectsnap.ui.manager.pages
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -32,6 +43,7 @@ import me.eternal.purrfectsnap.storage.addRepo
import me.eternal.purrfectsnap.storage.getRepositories import me.eternal.purrfectsnap.storage.getRepositories
import me.eternal.purrfectsnap.storage.removeRepo import me.eternal.purrfectsnap.storage.removeRepo
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class ManageReposSection: Routes.Route() { class ManageReposSection: Routes.Route() {
@@ -165,44 +177,74 @@ class ManageReposSection: Routes.Route() {
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) { val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
context.database.getRepositories(repoType) context.database.getRepositories(repoType)
} }
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LazyColumn( LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
modifier = Modifier.fillMaxSize(), val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding), routes.navigation?.globalScrollOffset = offset
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) { ) {
item { LazyColumn(
if (repositories.isEmpty()) { modifier = Modifier.fillMaxSize(),
Text(translation["no_repos_added"], modifier = Modifier state = listState,
.padding(16.dp) contentPadding = PaddingValues(start = 8.dp, top = controlsHeight, end = 8.dp, bottom = routes.bottomPadding),
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center) ) {
item {
if (repositories.isEmpty()) {
Text(translation["no_repos_added"], modifier = Modifier
.padding(16.dp)
.padding(top = 40.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center, color = Color.White)
}
} }
} items(repositories) { url ->
items(repositories) { url -> Surface(
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
}, modifier = Modifier.animateContentSize()) {
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(horizontal = 10.dp, vertical = 6.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp), .clickable { context.androidContext.copyToClipboard(url) },
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically shape = RoundedCornerShape(20.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) { ) {
Icon(Icons.Default.Public, contentDescription = null) Row(
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp) modifier = Modifier
Button( .fillMaxWidth()
onClick = { .padding(14.dp),
context.database.removeRepo(repoType, url) verticalAlignment = Alignment.CenterVertically,
coroutineScope.launch { horizontalArrangement = Arrangement.spacedBy(12.dp)
updateDispatcher.dispatch()
}
}
) { ) {
Text(translation["remove_button"]) Icon(Icons.Default.Public, contentDescription = null, tint = Color.White)
Text(text = url, color = Color.White, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1, fontSize = 14.sp)
Button(
onClick = {
context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
},
colors = ButtonDefaults.buttonColors(containerColor = Color.White.copy(alpha = 0.1f), contentColor = Color.White)
) {
Text(translation["remove_button"])
}
} }
} }
} }
} }
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = remember(repoType) { translation.format("title", "type" to repoType) },
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
} }
} }
} }

View File

@@ -14,8 +14,8 @@ import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,6 +41,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -68,6 +70,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@@ -105,7 +108,6 @@ class TasksRootSection : Routes.Route() {
runCatching { runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}") pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
//copy with progress
val length = documentFile.length().toFloat() val length = documentFile.length().toFloat()
tempFile.outputStream().use { outputStream -> tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024) val buffer = ByteArray(16 * 1024)
@@ -301,7 +303,7 @@ class TasksRootSection : Routes.Route() {
) )
Column { Column {
Text( Text(
text = context.translation["delete_files_option"], text = context.translation["delete_files_option"] ?: "Delete Files",
color = Color.White, color = Color.White,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
@@ -389,59 +391,7 @@ class TasksRootSection : Routes.Route() {
} }
} }
override val topBarActions: @Composable (RowScope.() -> Unit) = { override val topBarActions: @Composable (RowScope.() -> Unit) = {}
var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
onClick = {
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
}
}
IconButton(onClick = {
showConfirmDialog = true
}) {
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"])
}
if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },
onConfirm = {
showConfirmDialog = false
clearTasks(alsoDeleteFiles, coroutineScope)
},
onDismiss = { showConfirmDialog = false }
)
}
}
@Composable @Composable
private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -613,7 +563,7 @@ class TasksRootSection : Routes.Route() {
} else { } else {
when { when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") documentFileMimeType.contains("image") -> Icon(Icons.Filled.Photo, contentDescription = "Image")
documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video")
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
@@ -674,11 +624,9 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
if (task.type == TaskType.SCHEDULED_SEND) { if (task.type == TaskType.SCHEDULED_SEND) {
// Professional design for scheduled send tasks
Column( Column(
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
// Feature title
Text( Text(
context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps", context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
@@ -686,7 +634,6 @@ class TasksRootSection : Routes.Route() {
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
) )
// Scheduled time without icon
Text( Text(
task.title, task.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -694,7 +641,6 @@ class TasksRootSection : Routes.Route() {
color = Color.White color = Color.White
) )
// Recipients with icon
task.author?.takeIf { it != "null" }?.let { recipients -> task.author?.takeIf { it != "null" }?.let { recipients ->
Row( Row(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
@@ -844,6 +790,14 @@ class TasksRootSection : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(scrollState.firstVisibleItemScrollOffset, scrollState.firstVisibleItemIndex) {
val offset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
recentTasks = remember { mutableStateListOf() } recentTasks = remember { mutableStateListOf() }
var lastFetchedTaskId by remember { mutableStateOf(null as Long?) } var lastFetchedTaskId by remember { mutableStateOf(null as Long?) }
@@ -883,151 +837,98 @@ class TasksRootSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Surface( val subtitle = if (activeTasks.isNotEmpty()) {
modifier = Modifier translation.format(
.fillMaxWidth() "summary_active",
.padding(horizontal = 14.dp, vertical = 12.dp) "active" to activeTasks.size.toString(),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), "recent" to recentTasks.size.toString()
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
) )
) { } else {
Row( translation.format(
modifier = Modifier "summary_idle",
.fillMaxWidth() "recent" to recentTasks.size.toString()
.padding(horizontal = 16.dp, vertical = 14.dp), )
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceBetween
) { me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { title = context.translation["manager.routes.tasks"] ?: "Tasks",
Text( subtitle = subtitle,
text = context.translation["manager.routes.tasks"], scrollOffset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset,
color = Color.White, modifier = Modifier.headerHeightTracker { controlsHeight = it },
fontWeight = FontWeight.ExtraBold, actions = {
fontSize = 18.sp if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
) Surface(
Text( shape = RoundedCornerShape(50),
text = if (activeTasks.isNotEmpty()) { color = Color.White.copy(alpha = 0.1f),
translation.format( modifier = Modifier
"summary_active", .padding(end = 8.dp)
"active" to activeTasks.size.toString(), .clickable {
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
},
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
mergeSelection( mergeSelection(
taskSelection.toList().also { taskSelection.clear() } taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! } .map { it.first to it.second!! }
) )
},
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))
)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White)
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
} }
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Icon(Icons.Filled.PlaylistAddCheckCircle, contentDescription = null, tint = Color.White) Icon(
Icons.Filled.Merge,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text( Text(
text = translation.format("running_count", "count" to activeTasks.size.toString()), translation["merge_button"],
color = Color.White, color = Color.White,
fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
fontSize = 12.sp fontWeight = FontWeight.Bold
) )
} }
} }
IconButton(onClick = { showConfirmDialog = true }) { }
Icon(Icons.Filled.Delete, contentDescription = translation["clear_button_description"], tint = Color.White) IconButton(onClick = {
if (taskSelection.isEmpty()) {
showConfirmDialog = true
} else {
showConfirmDialog = true
} }
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
} }
} }
} )
Surface( Spacer(modifier = Modifier.height(8.dp))
modifier = Modifier
.weight(1f) LazyColumn(
.fillMaxWidth() state = scrollState,
.padding(horizontal = 12.dp), modifier = Modifier.fillMaxSize(),
shape = RoundedCornerShape(22.dp), contentPadding = PaddingValues(
color = Color.White.copy(alpha = 0.04f), start = 12.dp,
tonalElevation = 0.dp, end = 12.dp,
shadowElevation = 0.dp, top = controlsHeight,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
LazyColumn( item {
state = scrollState, if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
modifier = Modifier.fillMaxSize(), TasksEmptyState(text = translation["no_tasks"])
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 0.dp,
bottom = routes.bottomPadding + 16.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
TasksEmptyState(text = translation["no_tasks"])
}
} }
items(activeTasks, key = { it.taskId }) { pendingTask -> }
TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) items(activeTasks, key = { it.taskId }) { pendingTask ->
} TaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
items(recentTasks, key = { it.hash }) { task -> }
TaskCard(modifier = Modifier.fillMaxWidth(), task) items(recentTasks, key = { it.hash }) { task ->
} TaskCard(modifier = Modifier.fillMaxWidth(), task)
item { }
Spacer(modifier = Modifier.height(40.dp)) item {
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { Spacer(modifier = Modifier.height(40.dp))
fetchNewRecentTasks() LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
} fetchNewRecentTasks()
} }
} }
} }
@@ -1045,8 +946,8 @@ class TasksRootSection : Routes.Route() {
TaskDangerDialog( TaskDangerDialog(
visible = showConfirmDialog, visible = showConfirmDialog,
title = titleText, title = titleText ?: "",
message = messageText, message = messageText ?: "",
showDeleteFiles = isSelection, showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles, deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it }, onToggleDeleteFiles = { alsoDeleteFiles = it },

View File

@@ -1,45 +1,24 @@
package me.eternal.purrfectsnap.ui.manager.pages.features package me.eternal.purrfectsnap.ui.manager.pages.features
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon import androidx.compose.material3.*
import androidx.compose.material3.IconButton import androidx.compose.runtime.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@@ -50,7 +29,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.ui.manager.Routes 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.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.saveFile import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray import org.json.JSONArray
@@ -147,189 +128,102 @@ class ConfigExportSummaryScreen : Routes.Route() {
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations)) parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
} }
val expandedState = remember { mutableStateMapOf<String, Boolean>() } val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding() .padding(horizontal = 12.dp),
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom)) state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Surface( items(featuresByCategory.toList()) { (category, features) ->
modifier = Modifier val isExpanded = expandedState[category] ?: false
.fillMaxWidth() val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
.padding(horizontal = 14.dp, vertical = 12.dp), Surface(
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Row(
modifier = Modifier modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp)) .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp), .clickable { expandedState[category] = !isExpanded },
verticalAlignment = Alignment.CenterVertically shape = RoundedCornerShape(18.dp),
) { color = PurrfectPalette.cardOverlayColor,
Button( tonalElevation = 0.dp,
onClick = { routes.navController.popBackStack() }, shadowElevation = 10.dp,
colors = ButtonDefaults.buttonColors( border = BorderStroke(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f), 1.dp,
contentColor = Color.White Brush.linearGradient(
) listOf(
) { PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
Icon( PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {
context.longToast(
context.translation.format(
"manager.sections.features.config_export_failure_toast",
"error" to it.message.toString()
)
)
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(exportLabel)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
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 },
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.4f),
PurrfectPalette.glowSecondary.copy(alpha = 0.32f)
)
) )
) )
) { )
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Column(modifier = Modifier.weight(1f)) {
text = category, Text(
fontWeight = FontWeight.Bold, text = category,
fontSize = 17.sp, fontWeight = FontWeight.Bold,
color = Color.White fontSize = 17.sp,
) color = Color.White
} )
IconButton(onClick = { expandedState[category] = !isExpanded }) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = translation["expand_button_description"],
modifier = Modifier.graphicsLayer(rotationZ = rotationState),
tint = Color.White
)
}
} }
AnimatedVisibility(visible = isExpanded) { IconButton(onClick = { expandedState[category] = !isExpanded }) {
Column( Icon(
modifier = Modifier imageVector = Icons.Default.KeyboardArrowDown,
.padding(top = 10.dp), contentDescription = translation["expand_button_description"],
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier.graphicsLayer(rotationZ = rotationState),
) { tint = Color.White
features.forEachIndexed { index, feature -> )
when (val parsedValue = parser.parseValue(feature.key, feature.value)) { }
is List<*> -> { }
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier
.padding(top = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
features.forEachIndexed { index, feature ->
when (val parsedValue = parser.parseValue(feature.key, feature.value)) {
is List<*> -> {
Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column( Column(
modifier = Modifier.padding(start = (feature.indentation * 16).dp), modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text( parsedValue.forEachIndexed { itemIndex, item ->
text = feature.name, Row(
fontWeight = FontWeight.SemiBold, horizontalArrangement = Arrangement.spacedBy(10.dp),
color = Color.White verticalAlignment = Alignment.CenterVertically
) ) {
Column( NumberBubble(itemIndex + 1)
modifier = Modifier.padding(start = 6.dp), Text(
verticalArrangement = Arrangement.spacedBy(6.dp) text = item.toString(),
) { color = PurrfectPalette.textSecondary
parsedValue.forEachIndexed { itemIndex, item -> )
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
text = item.toString(),
color = PurrfectPalette.textSecondary
)
}
} }
} }
} }
}
} }
is String -> { is String -> {
Column( Column(
@@ -354,7 +248,6 @@ class ConfigExportSummaryScreen : Routes.Route() {
if (index < features.size - 1) { if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
} }
}
} }
} }
} }
@@ -362,6 +255,35 @@ class ConfigExportSummaryScreen : Routes.Route() {
} }
} }
} }
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
routes.activityLauncher.saveFile("config.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(android.net.Uri.parse(uri))?.use {
context.config.writeConfig()
context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations).byteInputStream().copyTo(it)
context.shortToast(context.translation["manager.sections.features.config_export_success_toast"])
}
}.onFailure {
context.longToast(
context.translation.format(
"manager.sections.features.config_export_failure_toast",
"error" to it.message.toString()
)
)
}
}
}) {
Icon(imageVector = Icons.Default.ArrowDownward, contentDescription = exportLabel, tint = Color.White)
}
}
)
} }
} }

View File

@@ -1,43 +1,24 @@
package me.eternal.purrfectsnap.ui.manager.pages.features package me.eternal.purrfectsnap.ui.manager.pages.features
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@@ -53,7 +34,9 @@ import me.eternal.purrfectsnap.bridge.location.LocationCoordinates
import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate import me.eternal.purrfectsnap.storage.addOrUpdateLocationCoordinate
import me.eternal.purrfectsnap.storage.getLocationCoordinates import me.eternal.purrfectsnap.storage.getLocationCoordinates
import me.eternal.purrfectsnap.ui.manager.Routes 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.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.abs import kotlin.math.abs
@@ -73,10 +56,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
private const val COORDINATE_TOLERANCE = 0.0001 // ~11 meters tolerance for de-duplication 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) { private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
val existingLocations = context.database.getLocationCoordinates() val existingLocations = context.database.getLocationCoordinates()
@@ -87,14 +66,12 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val longitude = locationObj.get("longitude")?.asDouble ?: continue val longitude = locationObj.get("longitude")?.asDouble ?: continue
val radius = locationObj.get("radius")?.asDouble ?: 100.0 val radius = locationObj.get("radius")?.asDouble ?: 100.0
// Check for existing location with similar coordinates (de-duplication)
val existingMatch = existingLocations.find { existing -> val existingMatch = existingLocations.find { existing ->
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE && abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
} }
if (existingMatch == null) { if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply { val newLocation = LocationCoordinates().apply {
this.name = name this.name = name
this.latitude = latitude this.latitude = latitude
@@ -103,7 +80,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
} }
context.database.addOrUpdateLocationCoordinate(null, newLocation) context.database.addOrUpdateLocationCoordinate(null, newLocation)
} }
// If duplicate exists, skip (do not update or delete existing)
} }
} }
@@ -233,111 +209,26 @@ class ConfigImportConfirmationScreen : Routes.Route() {
} }
val expandedState = remember { mutableStateMapOf<String, Boolean>() } val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val importLabel = translation["confirm_button"] val importLabel = translation["confirm_button"]
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding() .padding(horizontal = 12.dp),
state = listState,
contentPadding = PaddingValues(
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Row(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(24.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { routes.navController.popBackStack() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White,
modifier = Modifier.padding(end = 6.dp)
)
Text(context.translation["common.back"])
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Text(
text = translation["title"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
}
Button(
onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
val savedLocationsJson = context.config.loadFromString(json)
// Import saved locations if present in the JSON
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}
context.shortToast(translation["config_imported_toast"])
context.coroutineScope.launch(Dispatchers.Main) {
routes.features.navigateReload()
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.3f),
contentColor = Color.White
)
) {
Text(importLabel)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 16.dp + routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
items(featuresByCategory.toList()) { (category, features) -> items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f) val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
@@ -439,15 +330,46 @@ class ConfigImportConfirmationScreen : Routes.Route() {
} }
if (index < features.size - 1) { if (index < features.size - 1) {
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
}
}
}
} }
} }
} }
} }
}
} }
}
} FloatingTopBar(
} title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = listState.firstVisibleItemScrollOffset + (listState.firstVisibleItemIndex * Motion.HEADER_MORPH_THRESHOLD.toInt()),
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = {
routes.configJsonForImport?.let { json ->
runCatching {
val savedLocationsJson = context.config.loadFromString(json)
savedLocationsJson?.let { locationsArray ->
importSavedLocations(locationsArray)
}
}.onFailure { err ->
context.longToast(
context.translation.format(
"config_import_failure_toast",
"error" to (err.message ?: context.translation["common.unknown_error"])
)
)
}
context.shortToast(translation["config_imported_toast"])
context.coroutineScope.launch(Dispatchers.Main) {
routes.features.navigateReload()
}
}
}) {
Icon(imageVector = Icons.Default.Check, contentDescription = importLabel, tint = Color.White)
}
}
)
} }
} }

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -48,6 +47,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog
import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog.Actions import me.eternal.purrfectsnap.ui.manager.pages.social.AddFriendDialog.Actions
@@ -223,25 +223,17 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
val density = LocalDensity.current val scrollState = rememberScrollState()
var topBarHeight by remember { mutableStateOf(96.dp) } val density = androidx.compose.ui.platform.LocalDensity.current
FloatingTopBar( var controlsHeight by remember { mutableStateOf(100.dp) }
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() }, Column(
modifier = Modifier modifier = Modifier
.zIndex(2f) .fillMaxSize()
.onGloballyPositioned { .verticalScroll(scrollState)
val newHeight = with(density) { it.size.height.toDp() } .padding(top = controlsHeight)
if (newHeight != topBarHeight) topBarHeight = newHeight .padding(horizontal = 12.dp, vertical = 10.dp),
}
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
val headerShape = RoundedCornerShape(22.dp) val headerShape = RoundedCornerShape(22.dp)
@@ -397,6 +389,13 @@ class ManageRuleFeature : Routes.Route() {
Spacer(modifier = Modifier.height(routes.bottomPadding)) Spacer(modifier = Modifier.height(routes.bottomPadding))
} }
FloatingTopBar(
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
} }
} }
} }

View File

@@ -1,40 +1,19 @@
package me.eternal.purrfectsnap.ui.manager.pages.home package me.eternal.purrfectsnap.ui.manager.pages.home
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults import androidx.compose.runtime.*
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -44,6 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -57,9 +37,12 @@ import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import android.os.SystemClock import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
class HomeAbout : Routes.Route() { class HomeAbout : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember { val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
@@ -67,9 +50,7 @@ class HomeAbout : Routes.Route() {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"] } val aboutStory = remember { translation["about_story"] }
val pagePadding = 16.dp val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding + val bottomPadding = routes.bottomPadding
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val tapSource = remember { MutableInteractionSource() } val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) } val tapCount = remember { mutableIntStateOf(0) }
@@ -79,24 +60,23 @@ class HomeAbout : Routes.Route() {
context.shortToast(translation["about_magic_toast"]) context.shortToast(translation["about_magic_toast"])
} }
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
var controlsHeight by remember { mutableStateOf(100.dp) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(bottom = bottomPadding) .padding(top = controlsHeight, bottom = bottomPadding)
) { ) {
FloatingTopBar(
title = routeInfo.translatedKey?.value ?: translation["manager.routes.home_about"],
onBack = { routes.navController.popBackStack() }
)
Spacer(modifier = Modifier.height(8.dp))
Surface( Surface(
modifier = Modifier modifier = Modifier
.padding(horizontal = pagePadding) .padding(horizontal = pagePadding)
@@ -156,13 +136,13 @@ class HomeAbout : Routes.Route() {
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
DeveloperCard( DeveloperCard(
name = "ΞTΞRNAL", name = translation["about_dev_external"],
imageRes = R.drawable.pfp_external, imageRes = R.drawable.pfp_external,
avenirNext = avenirNext, avenirNext = avenirNext,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
DeveloperCard( DeveloperCard(
name = "<RSR/>", name = translation["about_dev_rsr"],
imageRes = R.drawable.pfp_rsr, imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext, avenirNext = avenirNext,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -176,7 +156,8 @@ class HomeAbout : Routes.Route() {
Surface( Surface(
modifier = Modifier modifier = Modifier
.padding(horizontal = pagePadding) .padding(horizontal = pagePadding)
.fillMaxWidth(), .fillMaxWidth()
.widthIn(max = 500.dp),
shape = RoundedCornerShape(26.dp), shape = RoundedCornerShape(26.dp),
color = Color.Transparent, color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
@@ -186,20 +167,24 @@ class HomeAbout : Routes.Route() {
Column( Column(
modifier = Modifier modifier = Modifier
.background(PurrfectPalette.cardOverlay) .background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp), .padding(horizontal = 24.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = translation["about_story_title"], text = translation["about_story_title"],
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White,
textAlign = TextAlign.Center
) )
Text( Text(
text = aboutStory, text = aboutStory,
fontSize = 14.sp, fontSize = 14.sp,
color = PurrfectPalette.textSecondary, color = PurrfectPalette.textSecondary,
lineHeight = 20.sp textAlign = TextAlign.Justify,
lineHeight = 22.sp,
modifier = Modifier.fillMaxWidth()
) )
} }
} }
@@ -278,9 +263,14 @@ class HomeAbout : Routes.Route() {
} }
} }
} }
Spacer(modifier = Modifier.height(32.dp))
} }
FloatingTopBar(
title = context.translation["manager.routes.home_about"] ?: "About Us",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it }
)
} }
} }
@@ -316,10 +306,10 @@ class HomeAbout : Routes.Route() {
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(82.dp) .size(76.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f)) .background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape) .border(1.5.dp, imageRing, CircleShape)
) { ) {
Image( Image(
painter = painterResource(id = imageRes), painter = painterResource(id = imageRes),
@@ -328,16 +318,17 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
Text( PurrfectMarqueeText(
text = name, text = name,
fontSize = 16.sp, style = TextStyle(
fontWeight = FontWeight.Bold, fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = avenirNext
),
color = Color.White, color = Color.White,
fontFamily = avenirNext, modifier = Modifier.fillMaxWidth()
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }
} }
} }

View File

@@ -64,8 +64,11 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes 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.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator import me.eternal.purrfectsnap.ui.util.pullrefresh.PullRefreshIndicator
import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh import me.eternal.purrfectsnap.ui.util.pullrefresh.pullRefresh
import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState import me.eternal.purrfectsnap.ui.util.pullrefresh.rememberPullRefreshState
@@ -73,9 +76,9 @@ import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
class HomeLogs : Routes.Route() { class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) } private val logListState = LazyListState()
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0) private val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
} }
@@ -109,18 +112,19 @@ class HomeLogs : Routes.Route() {
override val topBarActions: @Composable (RowScope.() -> Unit) = {} override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) } var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() } val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() { fun refreshLogs() {
coroutineScope.launch { coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) { val readerResult = withContext(Dispatchers.IO) {
runCatching { runCatching {
context.log.newReader { line -> context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader if (shouldHideLog(line)) return@newReader
mainExecutor.execute { coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line) visibleLogs.add(line)
} }
} }
@@ -141,8 +145,7 @@ class HomeLogs : Routes.Route() {
} }
delay(220) delay(220)
if (visibleLogs.isNotEmpty()) { if (visibleLogs.isNotEmpty()) {
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0) logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
logListState.scrollToItem(targetIndex)
} }
isRefreshing = false isRefreshing = false
} }
@@ -167,49 +170,98 @@ class HomeLogs : Routes.Route() {
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState) .pullRefresh(pullRefreshState)
) { ) {
Column(modifier = Modifier.fillMaxSize()) { var showDropDown by remember { mutableStateOf(false) }
LogsFloatingBar(
isRefreshing = isRefreshing, Surface(
onRefresh = { modifier = Modifier
isRefreshing = true .fillMaxSize()
refreshLogs() .padding(horizontal = 12.dp),
}, shape = RoundedCornerShape(24.dp),
onExport = { exportLogs() }, color = Color.White.copy(alpha = 0.04f),
onClear = { clearLogsAndReload() } tonalElevation = 0.dp,
) shadowElevation = 0.dp,
Spacer(modifier = Modifier.height(12.dp)) border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
Surface( ) {
modifier = Modifier if (visibleLogs.isEmpty() && logReader != null) {
.weight(1f) EmptyLogsState()
.padding(horizontal = 12.dp), } else {
shape = RoundedCornerShape(24.dp), LazyColumn(
color = Color.White.copy(alpha = 0.04f), modifier = Modifier
tonalElevation = 0.dp, .fillMaxSize(),
shadowElevation = 0.dp, state = logListState,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) verticalArrangement = Arrangement.spacedBy(8.dp),
) { contentPadding = PaddingValues(
if (visibleLogs.isEmpty() && logReader != null) { start = 8.dp,
EmptyLogsState() end = 8.dp,
} else { top = controlsHeight,
LazyColumn( bottom = routes.bottomPadding + 12.dp
modifier = Modifier )
.fillMaxSize(), ) {
state = logListState, items(visibleLogs, key = { it.hashCode() }) { line ->
verticalArrangement = Arrangement.spacedBy(8.dp), LogEntryCard(line = line, composeContext = composeContext)
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 12.dp,
bottom = routes.bottomPadding + 22.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
} }
} }
} }
} }
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = { isRefreshing = true; refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = Color.White)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
clearLogsAndReload()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
exportLogs()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
)
PullRefreshIndicator( PullRefreshIndicator(
refreshing = isRefreshing, refreshing = isRefreshing,
state = pullRefreshState, state = pullRefreshState,
@@ -270,116 +322,6 @@ class HomeLogs : Routes.Route() {
} }
} }
@Composable
private fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
var showDropDown by remember { mutableStateOf(false) }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = topPadding),
shape = RoundedCornerShape(28.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = Color.White)
}
Text(
text = routeInfo.translatedKey?.value ?: translation["manager.routes.home_logs"],
color = PurrfectPalette.textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = PurrfectPalette.glowSecondary
)
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}
Box {
IconButton(onClick = { showDropDown = true }) {
Icon(Icons.Filled.MoreVert, contentDescription = null, tint = PurrfectPalette.glowSecondary)
}
DropdownMenu(
expanded = showDropDown,
onDismissRequest = { showDropDown = false },
offset = DpOffset(0.dp, 8.dp),
containerColor = Color(0xFF161821),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
shape = RoundedCornerShape(14.dp)
) {
DropdownMenuItem(
onClick = {
onClear()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.DeleteSweep, contentDescription = null, tint = PurrfectPalette.glowPrimary) },
text = { Text(translation["clear_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowPrimary
)
)
DropdownMenuItem(
onClick = {
onExport()
showDropDown = false
},
leadingIcon = { Icon(Icons.Filled.Download, contentDescription = null, tint = PurrfectPalette.glowSecondary) },
text = { Text(translation["export_logs_button"], color = Color.White) },
colors = MenuDefaults.itemColors(
textColor = Color.White,
leadingIconColor = PurrfectPalette.glowSecondary
)
)
}
}
}
}
}
}
}
@Composable @Composable
private fun EmptyLogsState() { private fun EmptyLogsState() {
Column( Column(

View File

@@ -55,6 +55,7 @@ import me.eternal.purrfectsnap.task.UpdateCheckWorker
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.setup.Requirements import me.eternal.purrfectsnap.ui.setup.Requirements
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.AlertDialogs import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -203,7 +204,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp) Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp, color = Color.White)
Switch( Switch(
checked = value, checked = value,
onCheckedChange = null, onCheckedChange = null,
@@ -235,7 +236,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp) Text(text = text, modifier = Modifier.padding(start = 26.dp, end = 16.dp), fontSize = 14.sp, color = Color.White)
Switch( Switch(
checked = value, checked = value,
onCheckedChange = null, onCheckedChange = null,
@@ -280,8 +281,8 @@ class HomeSettings : Routes.Route() {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp) Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp, color = Color.White)
context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp) } context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.sp, color = PurrfectPalette.textSecondary) }
} }
IconButton(onClick = { takeAction() }, IconButton(onClick = { takeAction() },
modifier = Modifier.padding(end = 2.dp) modifier = Modifier.padding(end = 2.dp)
@@ -289,7 +290,8 @@ class HomeSettings : Routes.Route() {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew, imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = context.translation.getOrNull("actions.$key.name"), contentDescription = context.translation.getOrNull("actions.$key.name"),
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp),
tint = Color.White
) )
} }
} }
@@ -313,6 +315,11 @@ class HomeSettings : Routes.Route() {
val contextC = LocalContext.current val contextC = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
val positiveLabel = context.translation["button.positive"] val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"] val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"] val importLabel = context.translation["button.import"]
@@ -372,109 +379,27 @@ class HomeSettings : Routes.Route() {
} }
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
if (showResetSetupDialog) { Column(
AestheticDialog( modifier = Modifier
onDismissRequest = { showResetSetupDialog = false }, .fillMaxSize()
title = translation["reset_setup_dialog_title"], .verticalScroll(scrollState)
text = translation["reset_setup_dialog_text"], .padding(top = controlsHeight, bottom = routes.bottomPadding)
icon = Icons.Filled.Warning, ) {
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = android.content.Intent(
context.androidContext,
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
Column(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(topPadding))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
contentColor = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(onClick = { routes.navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = Color.White
)
}
Text(
text = translation["manager.routes.home_settings"],
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.verticalScroll(scrollState) .padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
GlassCard { GlassCard {
RowTitle(title = translation["actions_title"]) RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction -> EnumAction.entries.forEach { enumAction ->
@@ -486,56 +411,54 @@ class HomeSettings : Routes.Route() {
GlassCard { GlassCard {
RowTitle(title = translation["ui_settings_title"]) RowTitle(title = translation["ui_settings_title"])
ShiftedRow { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(
Row( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .heightIn(min = 55.dp),
.heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Text(text = translation["haptic_feedback_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
Text(text = translation["haptic_feedback_label"]) var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) } val hapticFeedback = LocalHapticFeedback.current
val hapticFeedback = LocalHapticFeedback.current Switch(
Switch( checked = hapticFeedbackEnabled,
checked = hapticFeedbackEnabled, onCheckedChange = {
onCheckedChange = { if (it) {
if (it) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
} hapticFeedbackEnabled = it
hapticFeedbackEnabled = it context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.root.global.uiSettings.hapticFeedback.set(it) context.config.writeConfig()
context.config.writeConfig() },
}, modifier = Modifier.padding(end = 26.dp),
modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors()
colors = purrfectSwitchColors() )
) }
} Row(
Row( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .heightIn(min = 55.dp),
.heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Text(text = translation["use_system_toasts_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
Text(text = translation["use_system_toasts_label"]) var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) } val hapticFeedback = LocalHapticFeedback.current
val hapticFeedback = LocalHapticFeedback.current Switch(
Switch( checked = useSystemToasts,
checked = useSystemToasts, onCheckedChange = {
onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) {
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
} useSystemToasts = it
useSystemToasts = it context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.root.global.uiSettings.useSystemToasts.set(it) context.config.writeConfig()
context.config.writeConfig() },
}, modifier = Modifier.padding(end = 26.dp),
modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors()
colors = purrfectSwitchColors() )
)
}
} }
} }
} }
@@ -546,31 +469,29 @@ class HomeSettings : Routes.Route() {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) } var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow { Row(
Row( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .heightIn(min = 55.dp),
.heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Text(text = translation["auto_update_check"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
Text(text = translation["auto_update_check"]) val hapticFeedback = LocalHapticFeedback.current
val hapticFeedback = LocalHapticFeedback.current Switch(
Switch( checked = autoUpdateCheck,
checked = autoUpdateCheck, onCheckedChange = {
onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) {
if (context.config.root.global.uiSettings.hapticFeedback.get()) { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) }
} autoUpdateCheck = it
autoUpdateCheck = it context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.root.global.updateSettings.autoUpdateCheck.set(it) context.config.writeConfig()
context.config.writeConfig() scheduleUpdateCheck()
scheduleUpdateCheck() },
}, modifier = Modifier.padding(end = 26.dp),
modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors()
colors = purrfectSwitchColors() )
)
}
} }
AnimatedVisibility(visible = autoUpdateCheck) { AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier val spacingModifier = Modifier
@@ -628,12 +549,14 @@ class HomeSettings : Routes.Route() {
text = translation["reset_setup_action"], text = translation["reset_setup_action"],
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
lineHeight = 20.sp lineHeight = 20.sp,
color = Color.White
) )
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew, imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = translation["reset_setup_action"], contentDescription = translation["reset_setup_action"],
modifier = Modifier.padding(end = 14.dp) modifier = Modifier.padding(end = 14.dp),
tint = Color.White
) )
} }
} }
@@ -933,35 +856,80 @@ class HomeSettings : Routes.Route() {
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)),
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Default.DeleteSweep, contentDescription = null, modifier = Modifier.size(18.dp), tint = Color.White)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text(translation["clear_button"]) Text(translation["clear_button"])
} }
} }
ShiftedRow { Column(
Column( verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), ) {
) { PremiumPreferenceToggle(
PremiumPreferenceToggle( context.sharedPreferences,
context.sharedPreferences, key = "test_mode",
key = "test_mode", text = translation["test_mode_label"],
text = translation["test_mode_label"], defaultValue = true,
defaultValue = true, confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableTitle = translation["purr_aura_disable_title"], confirmDisableText = translation["purr_aura_disable_text"]
confirmDisableText = translation["purr_aura_disable_text"] )
) PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"]) PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
} }
} }
} }
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
} }
} }
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.home_settings"] ?: "Settings",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollState.value,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = { routes.navigation?.openBottomBarCustomization = true }) {
Icon(
imageVector = Icons.Filled.Tune,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f)
)
}
}
)
if (showResetSetupDialog) {
AestheticDialog(
onDismissRequest = { showResetSetupDialog = false },
title = translation["reset_setup_dialog_title"],
text = translation["reset_setup_dialog_text"],
icon = Icons.Filled.Warning,
confirmButtonText = positiveLabel,
dismissButtonText = negativeLabel,
onConfirm = {
showResetSetupDialog = false
context.sharedPreferences.edit()
.remove("setup_in_progress")
.remove("setup_current_route")
.remove("setup_skip_patch")
.remove("setup_install_mode")
.apply()
context.config.reset()
context.config.writeConfig()
val intent = android.content.Intent(
context.androidContext,
me.eternal.purrfectsnap.ui.setup.SetupActivity::class.java
)
intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
context.androidContext.startActivity(intent)
routes.navController.popBackStack()
},
onDismiss = { showResetSetupDialog = false },
showCloseButton = false
)
}
} }
} }
} }
}

View File

@@ -73,6 +73,7 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class ManageScriptReposSection : Routes.Route() { class ManageScriptReposSection : Routes.Route() {
@@ -296,10 +297,7 @@ class ManageScriptReposSection : Routes.Route() {
onBack = { routes.navController.popBackStack() }, onBack = { routes.navController.popBackStack() },
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.onGloballyPositioned { .headerHeightTracker { topBarHeight = it }
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
) )
if (repositories.isEmpty()) { if (repositories.isEmpty()) {
Box( Box(
@@ -319,9 +317,9 @@ class ManageScriptReposSection : Routes.Route() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 12.dp, start = 12.dp,
top = topBarHeight + 12.dp, top = topBarHeight,
end = 12.dp, end = 12.dp,
bottom = 18.dp + routes.bottomPadding bottom = routes.bottomPadding
), ),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -22,12 +23,15 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@@ -38,6 +42,7 @@ import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface
import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher import me.eternal.purrfectsnap.common.ui.AsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard import me.eternal.purrfectsnap.common.util.ktx.getUrlFromClipboard
import me.eternal.purrfectsnap.common.util.ktx.openLink import me.eternal.purrfectsnap.common.util.ktx.openLink
@@ -47,6 +52,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.chooseFolder import me.eternal.purrfectsnap.ui.util.chooseFolder
@@ -59,7 +66,6 @@ class ScriptingRootSection : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.scripting") } override val translation by lazy { context.translation.getCategory("manager.scripting") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false) val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0)
override val init: () -> Unit = { override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!) activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -140,7 +146,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch return@launch
}.onFailure { }.onFailure {
context.log.error("Failed to import script", it) context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"]))) context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
} }
isLoading = false isLoading = false
} }
@@ -431,7 +437,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable @Composable
private fun SelectFolderButton(onClick: () -> Unit) { private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"] val label = translation.getOrNull("select_folder_button") ?: "Select folder"
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -439,13 +445,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Surface( Surface(
modifier = Modifier.size(78.dp), modifier = Modifier.size(68.dp),
shape = CircleShape, shape = CircleShape,
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f), color = Color.White.copy(alpha = 0.1f),
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = 14.dp, shadowElevation = 10.dp,
border = BorderStroke( border = BorderStroke(
1.5.dp, 1.dp,
Brush.linearGradient( Brush.linearGradient(
listOf( listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f), PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,18 +462,17 @@ class ScriptingRootSection : Routes.Route() {
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.padding(6.dp) .fillMaxSize()
.size(66.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
Brush.radialGradient( Brush.radialGradient(
colors = listOf( colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.42f), PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f) PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
) )
) )
) )
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -475,7 +480,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward, imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label, contentDescription = label,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(34.dp) modifier = Modifier.size(32.dp)
) )
} }
} }
@@ -500,6 +505,7 @@ class ScriptingRootSection : Routes.Route() {
} }
} }
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = { override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val scriptingFolder by rememberAsyncMutableState( val scriptingFolder by rememberAsyncMutableState(
defaultValue = null, defaultValue = null,
@@ -508,10 +514,14 @@ class ScriptingRootSection : Routes.Route() {
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"]) val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
var showToast by remember { mutableStateOf(false) } var showToast by remember { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
val coroutineScope = rememberCoroutineScope()
val pagerState = androidx.compose.foundation.pager.rememberPagerState { tabTitles.size }
LaunchedEffect(scriptingFolder) { LaunchedEffect(scriptingFolder) {
if (scriptingFolder == null && selectedTab != 0) { if (scriptingFolder == null && pagerState.currentPage != 0) {
selectedTab = 0 pagerState.scrollToPage(0)
} }
} }
@@ -532,12 +542,12 @@ class ScriptingRootSection : Routes.Route() {
) { ) {
ScriptingHeader( ScriptingHeader(
titles = tabTitles, titles = tabTitles,
selectedTab = selectedTab, selectedTab = pagerState.currentPage,
onTabSelected = { index -> onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) { if (index == 1 && scriptingFolder == null) {
showToast = true showToast = true
} else { } else {
selectedTab = index coroutineScope.launch { pagerState.animateScrollToPage(index) }
} }
}, },
onImport = { onImport = {
@@ -562,15 +572,99 @@ class ScriptingRootSection : Routes.Route() {
context.translation["toast_open_link_failed"] context.translation["toast_open_link_failed"]
) )
}, },
folderSelected = scriptingFolder != null folderSelected = scriptingFolder != null,
onPositioned = { controlsHeight = it }
) )
Spacer(Modifier.height(12.dp))
when (selectedTab) { androidx.compose.foundation.pager.HorizontalPager(
0 -> InstalledTabContent( modifier = Modifier.fillMaxSize(),
scriptingFolder = scriptingFolder state = pagerState,
) userScrollEnabled = scriptingFolder != null
1 -> CatalogTabContent( ) { page ->
scriptingFolder = scriptingFolder when (page) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder,
controlsHeight = controlsHeight
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder,
controlsHeight = controlsHeight
)
}
}
var scriptingWarning by remember {
mutableStateOf<Boolean>(context.sharedPreferences.run {
getBoolean("scripting_warning", true).also {
if (it) edit().putBoolean("scripting_warning", false).apply()
}
})
}
if (scriptingWarning) {
var timeout by remember { mutableIntStateOf(10) }
LaunchedEffect(Unit) {
while (timeout > 0) {
delay(1000)
timeout--
}
}
AestheticDialog(
onDismissRequest = { if (timeout == 0) scriptingWarning = false },
title = context.translation["manager.dialogs.scripting_warning.title"] ?: "Scripting Warning",
text = context.translation["manager.dialogs.scripting_warning.content"] ?: "Scripts can execute arbitrary code on your device. Only install scripts from trusted sources.",
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"] ?: "OK",
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
customContent = {
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.background(
Brush.radialGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
),
shape = RoundedCornerShape(18.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = timeout.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
}
}
}
) )
} }
} }
@@ -578,7 +672,8 @@ class ScriptingRootSection : Routes.Route() {
@Composable @Composable
private fun InstalledTabContent( private fun InstalledTabContent(
scriptingFolder: DocumentFile? scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) { ) {
val scriptModules by rememberAsyncMutableState( val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(), defaultValue = emptyList(),
@@ -605,229 +700,144 @@ class ScriptingRootSection : Routes.Route() {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 12.dp)
) { ) {
Surface( val listState = rememberLazyListState()
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f), LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
tonalElevation = 0.dp, val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
shadowElevation = 0.dp, routes.navigation?.globalScrollOffset = offset
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding, start = 8.dp, end = 8.dp, top = 12.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { item {
LazyColumn( if (scriptingFolder == null && !refreshing) {
modifier = Modifier Box(
.fillMaxSize() modifier = Modifier
.pullRefresh(pullRefreshState), .fillMaxWidth()
contentPadding = PaddingValues(bottom = routes.bottomPadding + 28.dp, start = 8.dp, end = 8.dp, top = 12.dp), .height(260.dp),
horizontalAlignment = Alignment.Start, contentAlignment = Alignment.Center
verticalArrangement = Arrangement.spacedBy(12.dp) ) {
) { Surface(
item { modifier = Modifier.fillMaxWidth(),
if (scriptingFolder == null && !refreshing) { shape = RoundedCornerShape(24.dp),
Box( color = Color.White.copy(alpha = 0.05f),
modifier = Modifier border = BorderStroke(
.fillMaxWidth() 1.dp,
.heightIn(min = 260.dp), Brush.linearGradient(
contentAlignment = Alignment.Center listOf(
) { PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
Surface( PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.6f),
PurrfectPalette.glowSecondary.copy(alpha = 0.45f)
)
)
) )
) { )
Column( )
modifier = Modifier ) {
.fillMaxWidth() Column(
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Box(
modifier = Modifier
.size(58.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.08f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(28.dp)
)
}
Text(
text = translation["no_scripts_folder_selected_title"],
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center,
color = Color.White
)
Text(
text = translation["select_scripts_folder_toast"],
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
SelectFolderButton(
onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch { reloadDispatcher.dispatch() }
}
}
)
}
}
}
} else if (scriptModules.isEmpty()) {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(220.dp), .padding(horizontal = 20.dp, vertical = 22.dp),
contentAlignment = Alignment.Center horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
AestheticEmptyState( Box(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"],
subtitle = translation["use_catalog_to_add_scripts"],
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(58.dp)
.padding(horizontal = 22.dp) .clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.08f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
tint = PurrfectPalette.glowSecondary,
modifier = Modifier.size(28.dp)
)
}
Text(
text = translation["no_scripts_folder_selected_title"] ?: "No scripts folder selected",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center,
color = Color.White
)
Text(
text = translation["select_scripts_folder_toast"] ?: "Please select a folder to store scripts.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
SelectFolderButton(
onClick = {
activityLauncherHelper.chooseFolder {
context.config.root.scripting.moduleFolder.set(it)
context.config.writeConfig()
coroutineScope.launch { reloadDispatcher.dispatch() }
}
}
) )
} }
} }
} }
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index -> } else if (scriptModules.isEmpty()) {
ModuleItem(scriptModules[index]) Box(
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
)
}
}
}
var scriptingWarning by remember {
mutableStateOf(context.sharedPreferences.run {
getBoolean("scripting_warning", true).also {
edit().putBoolean("scripting_warning", false).apply()
}
})
}
if (scriptingWarning) {
var timeout by remember { mutableIntStateOf(10) }
LaunchedEffect(Unit) {
while (timeout > 0) {
delay(1000)
timeout--
}
}
AestheticDialog(
onDismissRequest = { if (timeout == 0) scriptingWarning = false },
title = context.translation["manager.dialogs.scripting_warning.title"],
text = context.translation["manager.dialogs.scripting_warning.content"],
icon = Icons.Default.Warning,
confirmButtonText = translation["button.ok"],
onConfirm = { if (timeout == 0) scriptingWarning = false },
loading = timeout > 0,
showCloseButton = false,
customContent = {
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp), .height(220.dp),
horizontalAlignment = Alignment.CenterHorizontally, contentAlignment = Alignment.Center
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Box( AestheticEmptyState(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"] ?: "No scripts found",
subtitle = translation["use_catalog_to_add_scripts"] ?: "Check the catalog to find and install scripts.",
modifier = Modifier modifier = Modifier
.size(64.dp) .fillMaxWidth()
.background( .padding(horizontal = 22.dp)
Brush.radialGradient( )
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.25f),
PurrfectPalette.glowSecondary.copy(alpha = 0.18f)
)
),
shape = RoundedCornerShape(18.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = timeout.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 20.sp
)
}
} }
} }
} }
items(scriptModules.size, key = { scriptModules[it].hashCode() }) { index ->
ModuleItem(scriptModules[index])
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = pullRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 8.dp)
) )
} }
} }
@Composable @Composable
private fun CatalogTabContent( private fun CatalogTabContent(
scriptingFolder: DocumentFile? scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) { ) {
val coroutineScope = rememberCoroutineScope()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 12.dp)
) { ) {
Surface( if (scriptingFolder == null) {
shape = RoundedCornerShape(22.dp), AestheticEmptyState(
color = Color.White.copy(alpha = 0.04f), icon = Icons.Default.FolderOpen,
tonalElevation = 0.dp, title = translation["no_scripts_folder_selected_title"] ?: "No scripts folder selected",
shadowElevation = 0.dp, subtitle = translation["select_scripts_folder_toast"] ?: "Please select a folder to store scripts.",
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) modifier = Modifier
) { .fillMaxWidth()
if (scriptingFolder == null) { .padding(horizontal = 22.dp)
AestheticEmptyState( )
icon = Icons.Default.FolderOpen, } else {
title = translation["no_scripts_folder_selected_title"], ScriptCatalog(this@ScriptingRootSection)
subtitle = translation["select_scripts_folder_toast"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
} else {
ScriptCatalog(this@ScriptingRootSection)
}
} }
} }
} }
@@ -841,71 +851,51 @@ class ScriptingRootSection : Routes.Route() {
onOpenFolder: () -> Unit, onOpenFolder: () -> Unit,
onManageRepos: () -> Unit, onManageRepos: () -> Unit,
onDocs: () -> Unit, onDocs: () -> Unit,
folderSelected: Boolean folderSelected: Boolean,
onPositioned: (androidx.compose.ui.unit.Dp) -> Unit = {}
) { ) {
Surface( val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
modifier = Modifier val shrinkThreshold = 300f
.fillMaxWidth() val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
.padding(horizontal = 14.dp, vertical = 12.dp) val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp), Column(modifier = Modifier.headerHeightTracker(onPositioned), verticalArrangement = Arrangement.spacedBy(12.dp)) {
color = Color.White.copy(alpha = 0.07f), me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
tonalElevation = 0.dp, title = translation["manager.routes.scripts"] ?: "Scripts",
shadowElevation = 0.dp, subtitle = if (selectedTab == 0) translation["installed_scripts_tab"] else translation["catalog_tab"],
border = BorderStroke( scrollOffset = scrollOffset,
1.dp, actions = {
Brush.linearGradient( IconButton(onClick = onDocs) {
listOf( Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = translation["manager.routes.scripts"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
} }
Row( IconButton(onClick = onManageRepos) {
modifier = Modifier.wrapContentWidth(), Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
horizontalArrangement = Arrangement.spacedBy(8.dp), }
verticalAlignment = Alignment.CenterVertically IconButton(onClick = onOpenFolder) {
) { Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
IconButton(onClick = onDocs) { }
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White) IconButton(onClick = 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))
IconButton(onClick = onManageRepos) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_button"], tint = Color.White)
}
IconButton(onClick = onOpenFolder) {
Icon(Icons.Default.FolderOpen, contentDescription = translation["open_scripts_folder_button"], tint = Color.White)
}
IconButton(onClick = 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))
}
} }
} }
ScriptingTabSwitcher( )
titles = titles,
selectedTab = selectedTab, if (tabSwitcherAlpha > 0.05f) {
onTabSelected = onTabSelected Box(
) modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.graphicsLayer {
alpha = tabSwitcherAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
) {
ScriptingTabSwitcher(
titles = titles,
selectedTab = selectedTab,
onTabSelected = onTabSelected
)
}
} }
} }
} }
@@ -956,5 +946,5 @@ class ScriptingRootSection : Routes.Route() {
} }
} }
override val topBarActions: @Composable() (RowScope.() -> Unit) = {} override val topBarActions: @Composable RowScope.() -> Unit = {}
} }

View File

@@ -21,7 +21,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -47,6 +46,7 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.AlertDialogs import me.eternal.purrfectsnap.ui.util.AlertDialogs
import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -118,16 +118,13 @@ class ManageScope: Routes.Route() {
}, },
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.onGloballyPositioned { .headerHeightTracker { topBarHeight = it }
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
) )
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topBarHeight + 8.dp) .padding(top = topBarHeight)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
var bottomComposable by remember { var bottomComposable by remember {

View File

@@ -31,7 +31,6 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -59,6 +58,7 @@ import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -767,16 +767,13 @@ class MessagingPreview: Routes.Route() {
}, },
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.onGloballyPositioned { .headerHeightTracker { topBarHeight = it }
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
) )
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topBarHeight + 6.dp) .padding(top = topBarHeight)
.padding(horizontal = 14.dp) .padding(horizontal = 14.dp)
) { ) {
if (hasBridgeError) { if (hasBridgeError) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,11 +28,15 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
@@ -62,7 +67,8 @@ class SocialRootSection : Routes.Route() {
private fun ScopeList( private fun ScopeList(
scope: SocialScope, scope: SocialScope,
friends: List<MessagingFriendInfo>, friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo> groups: List<MessagingGroupInfo>,
controlsHeight: androidx.compose.ui.unit.Dp
) { ) {
val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() } val remainingHours = remember { context.config.root.streaksReminder.remainingHours.get() }
val list = when (scope) { val list = when (scope) {
@@ -70,10 +76,17 @@ class SocialRootSection : Routes.Route() {
SocialScope.FRIEND -> friends SocialScope.FRIEND -> friends
} }
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, bottom = routes.bottomPadding + 12.dp), state = listState,
contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = controlsHeight, bottom = routes.bottomPadding),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
//check if scope list is empty //check if scope list is empty
@@ -194,6 +207,8 @@ class SocialRootSection : Routes.Route() {
val pagerState = rememberPagerState { titles.size } val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
updateScopeLists() updateScopeLists()
@@ -234,10 +249,11 @@ class SocialRootSection : Routes.Route() {
onSearchToggle = { onSearchToggle = {
searchActive = !searchActive searchActive = !searchActive
if (!searchActive) searchQuery = "" if (!searchActive) searchQuery = ""
} },
onPositioned = { controlsHeight = it }
) )
if (searchActive) { if (searchActive) {
val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] val searchHint = context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search"
val searchShape = RoundedCornerShape(18.dp) val searchShape = RoundedCornerShape(18.dp)
val searchBorder = Brush.linearGradient( val searchBorder = Brush.linearGradient(
listOf( listOf(
@@ -307,8 +323,8 @@ class SocialRootSection : Routes.Route() {
state = pagerState state = pagerState
) { page -> ) { page ->
when (page) { when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups) 0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups) 1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
} }
} }
} }
@@ -375,7 +391,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp fontSize = 15.sp
) )
Text( Text(
text = translation["groups_tab"], text = translation["groups_tab"] ?: "Groups",
color = PurrfectPalette.textSecondary, color = PurrfectPalette.textSecondary,
fontSize = 12.sp fontSize = 12.sp
) )
@@ -485,68 +501,50 @@ class SocialRootSection : Routes.Route() {
friendCount: Int, friendCount: Int,
groupCount: Int, groupCount: Int,
searchActive: Boolean, searchActive: Boolean,
onSearchToggle: () -> Unit onSearchToggle: () -> Unit,
onPositioned: (Dp) -> Unit = {}
) { ) {
Surface( val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
modifier = Modifier val focusFactor = (scrollOffset / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
.fillMaxWidth() val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), Column(
shape = RoundedCornerShape(26.dp), modifier = Modifier.headerHeightTracker(onPositioned),
color = Color.White.copy(alpha = 0.07f), verticalArrangement = Arrangement.spacedBy(12.dp)
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) { ) {
Column( me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), title = translation["manager.routes.social"] ?: "Social",
verticalArrangement = Arrangement.spacedBy(12.dp) subtitle = if (pagerState.currentPage == 0) translation["friends_tab"] else translation["groups_tab"],
) { scrollOffset = scrollOffset,
Row( actions = {
modifier = Modifier.fillMaxWidth(), StatPill(label = translation["friends_tab"], value = friendCount)
horizontalArrangement = Arrangement.spacedBy(12.dp), StatPill(label = translation["groups_tab"], value = groupCount)
verticalAlignment = Alignment.CenterVertically IconButton(onClick = onSearchToggle) {
) { Icon(
Column( imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
modifier = Modifier.weight(1f), contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
verticalArrangement = Arrangement.spacedBy(4.dp) tint = Color.White
) {
Text(
text = translation["manager.routes.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
) )
} }
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
StatPill(label = translation["friends_tab"], value = friendCount)
StatPill(label = translation["groups_tab"], value = groupCount)
IconButton(onClick = onSearchToggle) {
Icon(
imageVector = if (searchActive) Icons.Filled.Close else Icons.Filled.Search,
contentDescription = if (searchActive) translation["close_search_button_description"] else translation["search_button_description"],
tint = Color.White
)
}
}
} }
SocialTabSwitcher( )
titles = titles,
pagerState = pagerState, if (tabSwitcherAlpha > 0.05f) {
onTabSelected = onTabSelected Box(
) modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.graphicsLayer {
alpha = tabSwitcherAlpha
translationY = (-10 * focusFactor).dp.toPx()
}
) {
SocialTabSwitcher(
titles = titles,
pagerState = pagerState,
onTabSelected = onTabSelected
)
}
} }
} }
} }
@@ -631,7 +629,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp fontSize = 15.sp
) )
Text( Text(
text = translation["social_empty_hint"], text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
color = PurrfectPalette.textSecondary, color = PurrfectPalette.textSecondary,
fontSize = 12.sp fontSize = 12.sp
) )

View File

@@ -496,7 +496,7 @@ class EditRule : Routes.Route() {
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
.padding(padding) .padding(padding)
) { ) {
val contentBottomPadding = routes.bottomPadding + 12.dp val contentBottomPadding = routes.bottomPadding
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
@@ -32,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -50,6 +50,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -69,7 +71,10 @@ class FriendTrackerCatalog : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") } override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") }
@Composable @Composable
private fun AvailableRulesTab(topBarHeight: Dp) { private fun AvailableRulesTab(
controlsHeight: androidx.compose.ui.unit.Dp,
onScrollOffsetChanged: (Int) -> Unit
) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() } val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson } val gson = remember { context.gson }
@@ -78,7 +83,6 @@ class FriendTrackerCatalog : Routes.Route() {
var repoIndexes by remember { mutableStateOf<Map<String, FriendTrackerRepoManifest>>(emptyMap()) } var repoIndexes by remember { mutableStateOf<Map<String, FriendTrackerRepoManifest>>(emptyMap()) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
// Ticks whenever a rule import happens so isImported values recompute
var importTick by remember { mutableStateOf(0) } var importTick by remember { mutableStateOf(0) }
fun refreshIndexes() { fun refreshIndexes() {
@@ -93,7 +97,7 @@ class FriendTrackerCatalog : Routes.Route() {
repos.forEach { repoRoot -> repos.forEach { repoRoot ->
val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json" val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json"
try { try {
val req = Request.Builder().url(indexUrl).build() // ktlint-disable indent_wrapped_argument val req = Request.Builder().url(indexUrl).build()
okHttpClient.newCall(req).execute().use { response -> okHttpClient.newCall(req).execute().use { response ->
if (response.isSuccessful) { if (response.isSuccessful) {
response.body?.charStream()?.let { reader -> response.body?.charStream()?.let { reader ->
@@ -160,14 +164,14 @@ class FriendTrackerCatalog : Routes.Route() {
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path
try { try {
val req = Request.Builder().url(rawUrl).build() // ktlint-disable indent_wrapped_argument val req = Request.Builder().url(rawUrl).build()
okHttpClient.newCall(req).execute().use { response -> okHttpClient.newCall(req).execute().use { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
withContext(Dispatchers.Main) { context.shortToast(translation.format("download_failed", "code" to response.code.toString())) } withContext(Dispatchers.Main) { context.shortToast(translation.format("download_failed", "code" to response.code.toString())) }
return@use return@use
} }
val content = response.body?.string() val content = response.body?.string()
if (content != null) { // ktlint-disable no-multi-spaces if (content != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
routes.friendTrackerConfigJsonForImport = content routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate() routes.friendTrackerConfigImport.navigate()
@@ -182,27 +186,21 @@ class FriendTrackerCatalog : Routes.Route() {
} }
} }
if (repositories.isEmpty() && !isLoading) { val listState = rememberLazyListState()
Box(
modifier = Modifier.fillMaxSize(), LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
contentAlignment = Alignment.Center val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
) { onScrollOffsetChanged(offset)
Text( }
text = translation["no_repos_added"],
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 8.dp, start = 8.dp,
top = topBarHeight + 12.dp, top = controlsHeight,
end = 8.dp, end = 8.dp,
bottom = 8.dp + routes.bottomPadding bottom = routes.bottomPadding
) )
) { ) {
item { item {
@@ -233,7 +231,6 @@ class FriendTrackerCatalog : Routes.Route() {
} }
} }
items(allRules) { (repoUrl, entry) -> items(allRules) { (repoUrl, entry) ->
// Compute isImported using produceState so the suspend db call runs in a coroutine
val isImported by produceState(initialValue = false, key1 = entry.name, key2 = importTick) { val isImported by produceState(initialValue = false, key1 = entry.name, key2 = importTick) {
val exists = withContext(Dispatchers.IO) { val exists = withContext(Dispatchers.IO) {
context.database.getTrackerRuleByName(entry.name) != null context.database.getTrackerRuleByName(entry.name) != null
@@ -327,35 +324,34 @@ class FriendTrackerCatalog : Routes.Route() {
} }
} }
} }
}
} }
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val density = LocalDensity.current var scrollOffset by remember { mutableIntStateOf(0) }
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val density = androidx.compose.ui.platform.LocalDensity.current
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) } var controlsHeight by remember { mutableStateOf(100.dp) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
AvailableRulesTab(
controlsHeight = controlsHeight,
onScrollOffsetChanged = { scrollOffset = it }
)
FloatingTopBar( FloatingTopBar(
title = translation["title"], title = translation["title"],
onBack = { routes.navController.popBackStack() }, onBack = { routes.navController.popBackStack() },
scrollOffset = scrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = { actions = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) { IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
Icon(Icons.Default.Public, contentDescription = translation["manage_repos_description"], tint = Color.White) Icon(Icons.Default.Public, contentDescription = translation["manage_repos_description"], tint = Color.White)
} }
}, }
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
) )
AvailableRulesTab(topBarHeight = topBarHeight)
} }
} }
} }

View File

@@ -8,12 +8,12 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoGraph import androidx.compose.material.icons.filled.AutoGraph
import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.DeleteOutline
@@ -25,13 +25,7 @@ import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Store import androidx.compose.material.icons.filled.Store
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -53,12 +47,12 @@ import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
import me.eternal.purrfectsnap.ui.util.openFile import me.eternal.purrfectsnap.ui.util.openFile
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pagerTabIndicatorOffset
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
class FriendTrackerManagerRoot : Routes.Route() { class FriendTrackerManagerRoot : Routes.Route() {
@@ -160,8 +154,13 @@ class FriendTrackerManagerRoot : Routes.Route() {
label: String, label: String,
icon: ImageVector, icon: ImageVector,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
scrollOffset: Int = 0
) { ) {
val shrinkThreshold = 300f
val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val labelAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
val shape = RoundedCornerShape(18.dp) val shape = RoundedCornerShape(18.dp)
val backgroundBrush = remember { val backgroundBrush = remember {
Brush.linearGradient( Brush.linearGradient(
@@ -185,123 +184,25 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(backgroundBrush, shape) .background(backgroundBrush, shape)
.padding(horizontal = 14.dp, vertical = 10.dp), .padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(if (labelAlpha > 0.05f) 8.dp else 0.dp)
) { ) {
Icon(icon, contentDescription = label, tint = Color.White) Icon(icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(20.dp))
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold) if (labelAlpha > 0.05f) {
Text(
label,
color = Color.White.copy(alpha = labelAlpha),
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Clip
)
}
} }
} }
} }
override val topBarActions: @Composable RowScope.() -> Unit = { override val topBarActions: @Composable RowScope.() -> Unit = {
var showExportDialog by remember { mutableStateOf(false) } // Handled via FloatingTopBar
var showSingleExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
if (showExportDialog) {
AestheticDialog(
onDismissRequest = { showExportDialog = false },
title = translation["export_dialog_title"],
text = translation["export_logs_dialog_confirm_text"],
icon = Icons.Default.SaveAlt,
confirmButtonText = translation["export_button"],
onConfirm = {
showExportDialog = false
routes.friendTrackerConfigExport.navigate()
},
dismissButtonText = translation["button.cancel"],
onDismiss = { showExportDialog = false },
opaque = true,
showCloseButton = false
)
}
if (showSingleExportDialog) {
val rules = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getTrackerRulesDesc()
}
SelectRuleDialog(
onDismissRequest = { showSingleExportDialog = false },
rules = rules,
onRuleSelected = { rule ->
showSingleExportDialog = false
routes.friendTrackerConfigExport.navigate {
this["rule_id"] = rule.id.toString()
}
},
translation = translation
)
}
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
routes.activityLauncher.openFile("application/json") { uri ->
runCatching {
val content = context.androidContext.contentResolver.openInputStream(android.net.Uri.parse(uri))?.use {
it.readBytes().toString(Charsets.UTF_8)
} ?: return@runCatching
val exportedData = context.gson.fromJson(content, me.eternal.purrfectsnap.common.data.ExportedTrackerData::class.java)
if (exportedData.type != type) {
showInvalidImportTypeDialog = true
return@runCatching
}
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
}.onFailure {
context.longToast(
translation.format("read_file_failed_toast", "message" to (it.message ?: ""))
)
}
}
}
if (showInvalidImportTypeDialog) {
AlertDialog(
onDismissRequest = { showInvalidImportTypeDialog = false },
title = { Text(translation["invalid_import_type_dialog_title"]) },
text = { Text(translation["invalid_import_type_dialog_text"]) },
confirmButton = {
Button(onClick = { showInvalidImportTypeDialog = false }) {
Text(translation["button.ok"])
}
}
)
}
if (showImportDialog) {
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["import_dialog_title"],
text = translation["import_dialog_subtitle"] ?: translation["import_dialog_title"],
icon = Icons.Default.FolderOpen,
confirmButtonText = translation["bulk_import_button"],
onConfirm = {
showImportDialog = false
handleImport(me.eternal.purrfectsnap.common.data.ExportType.BULK)
},
dismissButtonText = translation["individual_import_button"],
onDismiss = {
showImportDialog = false
handleImport(me.eternal.purrfectsnap.common.data.ExportType.SINGLE)
},
opaque = true,
showCloseButton = false
)
}
if (currentPage == 0) {
TrackerIconButton(
icon = Icons.Default.FolderOpen,
contentDescription = translation["import_button_description"],
onClick = { showImportDialog = true }
)
Spacer(Modifier.width(8.dp))
TrackerIconButton(
icon = Icons.Default.SaveAlt,
contentDescription = translation["export_button_description"],
onClick = { showExportDialog = true }
)
}
} }
private lateinit var activityLauncherHelper: ActivityLauncherHelper private lateinit var activityLauncherHelper: ActivityLauncherHelper
@@ -347,11 +248,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
@Composable @Composable
private fun ConfigRulesTab() { private fun ConfigRulesTab(scrollOffset: (Int) -> Unit) {
val updateRules = rememberAsyncUpdateDispatcher() val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) { val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
context.database.getTrackerRulesDesc() context.database.getTrackerRulesDesc()
} }
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
scrollOffset(offset)
}
@Composable @Composable
fun EmptyState(text: String) { fun EmptyState(text: String) {
Column( Column(
@@ -389,178 +297,175 @@ class FriendTrackerManagerRoot : Routes.Route() {
} }
} }
Column( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) { ) {
LazyColumn( item {
modifier = Modifier.weight(1f), if (rules.isEmpty()) {
contentPadding = PaddingValues(bottom = routes.bottomPadding) EmptyState(translation["no_rules_found"])
) { }
item { }
if (rules.isEmpty()) { items(rules, key = { it.id }) { rule ->
EmptyState(translation["no_rules_found"]) val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
} context.database.getTrackerRule(rule.id)?.name ?: translation["empty_rule_name"]
}
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getTrackerEvents(rule.id).size
}
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getRuleTrackerScopes(rule.id).size
}
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
context.database.getTrackerRule(rule.id)?.enabled ?: false
} }
items(rules, key = { it.id }) { rule ->
val ruleName by rememberAsyncMutableState(defaultValue = rule.name) {
context.database.getTrackerRule(rule.id)?.name ?: translation["empty_rule_name"]
}
val eventCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getTrackerEvents(rule.id).size
}
val scopeCount by rememberAsyncMutableState(defaultValue = 0) {
context.database.getRuleTrackerScopes(rule.id).size
}
var enabled by rememberAsyncMutableState(defaultValue = rule.enabled) {
context.database.getTrackerRule(rule.id)?.enabled ?: false
}
val ruleShape = RoundedCornerShape(20.dp) val ruleShape = RoundedCornerShape(20.dp)
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
routes.editRule.navigate { routes.editRule.navigate {
this["rule_id"] = rule.id.toString() this["rule_id"] = rule.id.toString()
}
} }
.padding(8.dp), }
shape = ruleShape, .padding(horizontal = 8.dp, vertical = 6.dp),
color = Color.Transparent, shape = ruleShape,
tonalElevation = 0.dp, color = Color.Transparent,
shadowElevation = 12.dp, tonalElevation = 0.dp,
border = BorderStroke( shadowElevation = 12.dp,
1.dp, border = BorderStroke(
Brush.linearGradient( 1.dp,
listOf( Brush.linearGradient(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f), listOf(
PurrfectPalette.glowSecondary.copy(alpha = 0.4f) PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
) PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
) )
) )
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, ruleShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Row( Surface(
modifier = Modifier shape = CircleShape,
.fillMaxWidth() color = Color.White.copy(alpha = 0.08f),
.background(PurrfectPalette.cardOverlay, ruleShape) tonalElevation = 0.dp,
.padding(horizontal = 14.dp, vertical = 12.dp), shadowElevation = 0.dp,
verticalAlignment = Alignment.CenterVertically, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Surface( Box(
shape = CircleShape, modifier = Modifier
color = Color.White.copy(alpha = 0.08f), .size(54.dp)
tonalElevation = 0.dp, .background(
shadowElevation = 0.dp, Brush.linearGradient(
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f)) listOf(
) { PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
Box( PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
modifier = Modifier )
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
CircleShape
), ),
contentAlignment = Alignment.Center CircleShape
) { ),
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White) contentAlignment = Alignment.Center
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White) Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
Text( }
buildString { }
append(eventCount) Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text(
buildString {
append(eventCount)
append(" ")
append(translation["events_suffix"])
if (scopeCount > 0) {
append("")
append(scopeCount)
append(" ") append(" ")
append(translation["events_suffix"]) append(translation["scopes_suffix"])
if (scopeCount > 0) { }
append("") },
append(scopeCount) fontSize = 12.sp,
append(" ") fontWeight = FontWeight.SemiBold,
append(translation["scopes_suffix"]) color = PurrfectPalette.textSecondary
} )
}, if (scopeCount > 0) {
fontSize = 12.sp, val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
fontWeight = FontWeight.SemiBold, context.database.getRuleTrackerScopes(rule.id, limit = 8).mapNotNull {
color = PurrfectPalette.textSecondary context.database.getFriendInfo(it.key)?.let { friend ->
) friend.selfieId to friend.bitmojiId
if (scopeCount > 0) {
val scopesBitmoji = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleTrackerScopes(rule.id, limit = 8).mapNotNull {
context.database.getFriendInfo(it.key)?.let { friend ->
friend.selfieId to friend.bitmojiId
}
} }
} }
Row( }
horizontalArrangement = Arrangement.spacedBy((-10).dp), Row(
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.spacedBy((-10).dp),
) { verticalAlignment = Alignment.CenterVertically
scopesBitmoji.take(4).forEach { friend -> ) {
BitmojiImage( scopesBitmoji.take(4).forEach { friend ->
size = 34, BitmojiImage(
size = 34,
modifier = Modifier
.border(BorderStroke(1.dp, Color.White), CircleShape)
.background(Color.White, CircleShape)
.clip(CircleShape),
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
)
}
if (scopeCount > scopesBitmoji.size) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = "+${scopeCount - scopesBitmoji.size}",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier modifier = Modifier
.border(BorderStroke(1.dp, Color.White), CircleShape) .padding(horizontal = 10.dp, vertical = 6.dp)
.background(Color.White, CircleShape)
.clip(CircleShape),
context = context,
url = BitmojiSelfie.getBitmojiSelfie(friend.first, friend.second, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D),
) )
} }
if (scopeCount > scopesBitmoji.size) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(
text = "+${scopeCount - scopesBitmoji.size}",
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
}
} }
} }
} }
Column( }
horizontalAlignment = Alignment.End, Column(
verticalArrangement = Arrangement.spacedBy(6.dp) horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) { ) {
Surface( Text(
shape = RoundedCornerShape(12.dp), text = translation[if (enabled) "enabled_label" else "disabled_label"],
color = Color.White.copy(alpha = 0.06f), color = Color.White,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) fontSize = 11.sp,
) { fontWeight = FontWeight.SemiBold,
Text( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
text = translation[if (enabled) "enabled_label" else "disabled_label"],
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
},
colors = purrfectSwitchColors()
) )
} }
Switch(
checked = enabled,
onCheckedChange = {
enabled = it
context.database.setTrackerRuleState(rule.id, it)
},
colors = purrfectSwitchColors()
)
} }
} }
} }
@@ -574,10 +479,14 @@ class FriendTrackerManagerRoot : Routes.Route() {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size } val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage currentPage = pagerState.currentPage
var scrollOffset by remember { mutableIntStateOf(0) }
var showExportDialog by remember { mutableStateOf(false) } var showExportDialog by remember { mutableStateOf(false) }
var showSingleExportDialog by remember { mutableStateOf(false) } var showSingleExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) } var showImportDialog by remember { mutableStateOf(false) }
var showInvalidImportTypeDialog by remember { mutableStateOf(false) } var showInvalidImportTypeDialog by remember { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) { fun handleImport(type: me.eternal.purrfectsnap.common.data.ExportType) {
routes.activityLauncher.openFile("application/json") { uri -> routes.activityLauncher.openFile("application/json") { uri ->
@@ -606,125 +515,77 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(PurrfectPalette.backgroundGradient) .background(PurrfectPalette.backgroundGradient)
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Surface( me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.friend_tracker"],
subtitle = titles.getOrNull(pagerState.currentPage) ?: "",
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (pagerState.currentPage == 0) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
scrollOffset = scrollOffset,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
scrollOffset = scrollOffset,
onClick = { showExportDialog = true }
)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp) .padding(horizontal = 14.dp, vertical = 12.dp),
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), horizontalArrangement = Arrangement.spacedBy(10.dp)
shape = RoundedCornerShape(26.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) { ) {
titles.forEachIndexed { i, text ->
val selected = pagerState.currentPage == i
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(18.dp)).clickable {
coroutineScope.launch { pagerState.animateScrollToPage(i) }
},
shape = RoundedCornerShape(18.dp),
color = if (selected) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.06f),
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row( Row(
modifier = Modifier modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.Center
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
} }
} }
}
}
Spacer(modifier = Modifier.height(8.dp)) HorizontalPager(
Surface(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .padding(horizontal = 4.dp, vertical = 4.dp),
.padding(horizontal = 12.dp), state = pagerState
shape = RoundedCornerShape(22.dp), ) { page ->
color = Color.White.copy(alpha = 0.04f), when (page) {
tonalElevation = 0.dp, 1 -> LogsTab(
shadowElevation = 0.dp, context = context,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) activityLauncherHelper = activityLauncherHelper,
) { deleteAction = { logDeleteAction = it },
Column(modifier = Modifier.fillMaxSize()) { exportAction = { exportAction = it },
Row( bottomPadding = routes.bottomPadding,
modifier = Modifier scrollOffset = { scrollOffset = it }
.fillMaxWidth() )
.padding(horizontal = 12.dp, vertical = 12.dp), 0 -> ConfigRulesTab(scrollOffset = { scrollOffset = it })
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
titles.forEachIndexed { i, text ->
val selected = pagerState.currentPage == i
Surface(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(18.dp)).clickable {
coroutineScope.launch { pagerState.animateScrollToPage(i) }
},
shape = RoundedCornerShape(18.dp),
color = if (selected) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.06f),
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
HorizontalPager(
modifier = Modifier
.weight(1f)
.padding(horizontal = 4.dp, vertical = 4.dp),
state = pagerState
) { page ->
when (page) {
1 -> LogsTab(
context = context,
activityLauncherHelper = activityLauncherHelper,
deleteAction = { logDeleteAction = it },
exportAction = { exportAction = it },
bottomPadding = routes.bottomPadding
)
0 -> ConfigRulesTab()
}
}
} }
} }
} }
@@ -735,8 +596,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showExportDialog = false }, onDismissRequest = { showExportDialog = false },
title = translation["export_dialog_title"], title = translation["export_dialog_title"],
choices = listOf( choices = listOf(
translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"]) }, translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"], tint = Color.White) },
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"]) } translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_export_button"], tint = Color.White) }
), ),
onChoiceSelected = { index -> onChoiceSelected = { index ->
showExportDialog = false showExportDialog = false
@@ -783,8 +644,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showImportDialog = false }, onDismissRequest = { showImportDialog = false },
title = translation["import_dialog_title"], title = translation["import_dialog_title"],
choices = listOf( choices = listOf(
translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"]) }, translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"], tint = Color.White) },
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"]) } translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_import_button"], tint = Color.White) }
), ),
onChoiceSelected = { index -> onChoiceSelected = { index ->
showImportDialog = false showImportDialog = false
@@ -806,33 +667,51 @@ private fun SelectRuleDialog(
translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
) { ) {
Dialog(onDismissRequest = onDismissRequest) { Dialog(onDismissRequest = onDismissRequest) {
Card( val shape = RoundedCornerShape(24.dp)
shape = RoundedCornerShape(16.dp), Surface(
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 20.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) { ) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text(translation["manager.friend_tracker.select_rule_to_export_title"], style = MaterialTheme.typography.headlineSmall) Text(
translation["manager.friend_tracker.select_rule_to_export_title"],
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.heightIn(max = 300.dp)
) { ) {
items(rules) { rule -> items(rules) { rule ->
ElevatedCard( Surface(
onClick = { onRuleSelected(rule) }, onClick = { onRuleSelected(rule) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) { ) {
Text( Text(
text = rule.name, text = rule.name,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
color = Color.White
) )
} }
} }
} }
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismissRequest) {
Text(translation["button.cancel"]) Text(translation["button.cancel"], color = PurrfectPalette.glowSecondary)
} }
} }
} }
@@ -847,7 +726,7 @@ private fun ChoiceDialog(
onChoiceSelected: (Int) -> Unit onChoiceSelected: (Int) -> Unit
) { ) {
Dialog(onDismissRequest = onDismissRequest) { Dialog(onDismissRequest = onDismissRequest) {
val shape = RoundedCornerShape(20.dp) val shape = RoundedCornerShape(24.dp)
Surface( Surface(
shape = shape, shape = shape,
color = Color.Transparent, color = Color.Transparent,
@@ -865,9 +744,9 @@ private fun ChoiceDialog(
Column( Column(
modifier = Modifier modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape) .background(PurrfectPalette.cardOverlay, shape)
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(14.dp)
) { ) {
Text( Text(
text = title, text = title,
@@ -876,36 +755,24 @@ private fun ChoiceDialog(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
choices.forEachIndexed { index, (text, icon) -> choices.forEachIndexed { index, (text, icon) ->
SelectButton( Surface(
onClick = { onChoiceSelected(index) }, onClick = { onChoiceSelected(index) },
text = text, modifier = Modifier.fillMaxWidth(),
leadingIcon = icon shape = RoundedCornerShape(18.dp),
) color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
icon()
Text(text = text, modifier = Modifier.weight(1f), color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
} }
} }
} }
} }
} }
@Composable
private fun SelectButton(
onClick: () -> Unit,
text: String,
leadingIcon: @Composable (() -> Unit)? = null,
) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (leadingIcon != null) {
leadingIcon()
}
Text(text = text, modifier = Modifier.weight(1f))
}
}
}

View File

@@ -1,5 +1,7 @@
package me.eternal.purrfectsnap.ui.manager.pages.tracker package me.eternal.purrfectsnap.ui.manager.pages.tracker
import me.eternal.purrfectsnap.ui.util.Motion
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -7,6 +9,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -61,6 +64,7 @@ fun LogsTab(
deleteAction: (() -> Unit) -> Unit, deleteAction: (() -> Unit) -> Unit,
exportAction: (() -> Unit) -> Unit, exportAction: (() -> Unit) -> Unit,
bottomPadding: Dp, bottomPadding: Dp,
scrollOffset: (Int) -> Unit
) { ) {
val translation = remember { context.translation.getCategory("manager.friend_tracker") } val translation = remember { context.translation.getCategory("manager.friend_tracker") }
val trackerTranslation = remember { context.translation.getCategory("tracker") } val trackerTranslation = remember { context.translation.getCategory("tracker") }
@@ -78,6 +82,12 @@ fun LogsTab(
var filter by remember { mutableStateOf("") } var filter by remember { mutableStateOf("") }
var searchTimeoutJob by remember { mutableStateOf<Job?>(null) } var searchTimeoutJob by remember { mutableStateOf<Job?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
scrollOffset(offset)
}
fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs( fun getPaginatedLogs(pageIndex: Int) = context.messageLogger.getLogs(
pageIndex = pageIndex, pageIndex = pageIndex,
pageSize = 30, pageSize = 30,
@@ -665,13 +675,15 @@ fun LogsTab(
) )
} }
} }
} }
} }
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = bottomPadding) state = listState,
) { contentPadding = PaddingValues(bottom = bottomPadding)
) {
item { item {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -73,6 +73,7 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState import me.eternal.purrfectsnap.ui.manager.components.AestheticEmptyState
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class ManageFriendTrackerReposSection: Routes.Route() { class ManageFriendTrackerReposSection: Routes.Route() {
@@ -286,10 +287,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
onBack = { routes.navController.popBackStack() }, onBack = { routes.navController.popBackStack() },
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.onGloballyPositioned { .headerHeightTracker { topBarHeight = it }
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
) )
if (repositories.isEmpty()) { if (repositories.isEmpty()) {
Box( Box(
@@ -309,9 +307,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 12.dp, start = 12.dp,
top = topBarHeight + 12.dp, top = topBarHeight,
end = 12.dp, end = 12.dp,
bottom = 18.dp + routes.bottomPadding bottom = routes.bottomPadding
), ),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {

View File

@@ -68,7 +68,7 @@ class RemoteOverlay(
containerColor = Color.Transparent, containerColor = Color.Transparent,
topBar = { navigation.TopBar() } topBar = { navigation.TopBar() }
) { innerPadding -> ) { innerPadding ->
navigation.NavContent( navigation.Content(
innerPadding, innerPadding,
startDestination = remember { startRoute(navigation.routes).routeInfo.id } startDestination = remember { startRoute(navigation.routes).routeInfo.id }
) )

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl package me.eternal.purrfectsnap.ui.setup.screens.impl
import android.net.Uri
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -104,7 +105,14 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary color = PurrfectPalette.textSecondary
) )
Text( Text(
text = if (currentFolder.isBlank()) context.translation["setup.save_folder.system_default_label"] else currentFolder, 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(":")
}.getOrDefault(currentFolder)
},
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color.White, color = Color.White,

View File

@@ -10,26 +10,31 @@ plugins {
// var versionName = "1.0.0" // var versionName = "1.0.0"
// var versionCode = 210 // var versionCode = 210
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
abstract class GetVersionTask : DefaultTask() { abstract class GetVersionTask : DefaultTask() {
@get:Input @get:Input
abstract val versionName: Property<String> abstract val versionName: Property<String>
@get:OutputFile
abstract val versionFile: RegularFileProperty
@TaskAction @TaskAction
fun writeVersion() { fun writeVersion() {
val versionFile = project.layout.projectDirectory.file("app/build/version.txt").asFile val file = versionFile.get().asFile
versionFile.parentFile.mkdirs() file.parentFile.mkdirs()
versionFile.writeText(versionName.get()) file.writeText(versionName.get())
} }
} }
tasks.register<GetVersionTask>("getVersion") { 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")) 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: // You can still set these for legacy use by submodules or scripts:

View File

@@ -247,7 +247,7 @@
"about_tagline": "Ein Xposed-Modul, um dein Snapchat-Erlebnis zu verbessern!", "about_tagline": "Ein Xposed-Modul, um dein Snapchat-Erlebnis zu verbessern!",
"about_lead_developers_title": "Hauptentwickler", "about_lead_developers_title": "Hauptentwickler",
"about_story_title": "Unsere Geschichte", "about_story_title": "Unsere Geschichte",
"about_story": "PurrfectSnap wurde am 2. Oktober 2025 als Fork von SnapEnhance durch ΞTΞRNAL gegründet, mit der Vision, Nutzern das hochwertige Snapchat-Erlebnis zu bieten, das sie verdienen. Diese App sollte eigentlich nur ein kleines Update im SnapEnhance-Repository sein, wurde aber bald zu einer eigenständigen App, in der die Mitwirkenden immer mehr Funktionen hinzufügten. Dann trat der Entwickler <RSR/> dem Team bei und die App wurde bald ein großer Erfolg. Wir erhielten viel Liebe und Unterstützung und erreichten 1K+ Downloads in nur zwei Tagen! Wir danken allen Nutzern und Mitwirkenden; ohne eure Unterstützung hätten wir diesen Punkt nicht erreicht. Wir möchten auch rhunk, dem Hauptentwickler von SnapEnhance, großen Dank aussprechen, denn ohne ihn würde diese App nicht einmal existieren. Wir sind ihm unendlich dankbar. Schließlich möchten wir all unseren Admins danken, insbesondere: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, die von Anfang an bei uns waren. Wir danken auch allen Testern, insbesondere Leo & Toxic, die kontinuierlich getestet und Fehler gemeldet haben. Wir sind unendlich dankbar für euren Beitrag.", "about_story": "PurrfectSnap wurde am 2. Oktober 2025 als Fork von SnapEnhance durch ΞTΞRNAL gegründet, mit der Vision, Nutzern das hochwertige Snapchat-Erlebnis zu bieten, das sie verdienen. Diese App sollte eigentlich nur ein kleines Update im SnapEnhance-Repository sein, wurde aber bald zu einer eigenständigen App, in der die Mitwirkenden immer mehr Funktionen hinzufügten. Dann trat der Entwickler <RSR/> dem Team bei und die App wurde bald ein großer Erfolg.\n\nWir erhielten viel Liebe und Unterstützung und erreichten 1K+ Downloads in nur zwei Tagen! Wir danken allen Nutzern und Mitwirkenden; ohne eure Unterstützung hätten wir diesen Punkt nicht erreicht.\n\nWir möchten auch rhunk, dem Hauptentwickler von SnapEnhance, großen Dank aussprechen, denn ohne ihn würde diese App nicht einmal existieren. Wir sind ihm unendlich dankbar.\n\nSchließlich möchten wir all unseren Admins danken, insbesondere: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, die von Anfang an bei uns waren. Wir danken auch allen Testern, insbesondere Leo & Toxic, die kontinuierlich getestet und Fehler gemeldet haben. Wir sind unendlich dankbar für euren Beitrag.",
"about_thanks_title": "Mit Liebe, PurrfectSnap Team", "about_thanks_title": "Mit Liebe, PurrfectSnap Team",
"about_magic_toast": "Tippe 5 Mal auf diesen Bildschirm, um etwas Magie zu sehen 😉!", "about_magic_toast": "Tippe 5 Mal auf diesen Bildschirm, um etwas Magie zu sehen 😉!",
"github_button": "GitHub", "github_button": "GitHub",

View File

@@ -247,7 +247,7 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!", "about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers", "about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story", "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. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, 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_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_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 😉!",
"github_button": "GitHub", "github_button": "GitHub",

View File

@@ -1,4 +1,4 @@
{ {
"setup": { "setup": {
"activity": { "activity": {
"wrong_apk_title": "Wrong APK installed", "wrong_apk_title": "Wrong APK installed",
@@ -207,6 +207,7 @@
"update_ready_label": "Ready to install", "update_ready_label": "Ready to install",
"purr_aura_active_label": "PurrAura Active!", "purr_aura_active_label": "PurrAura Active!",
"purr_aura_inactive_label": "PurrAura Inactive", "purr_aura_inactive_label": "PurrAura Inactive",
"about_meet_team_button": "About Us",
"open_settings_button": "Open Settings", "open_settings_button": "Open Settings",
"wiki_button": "Wiki", "wiki_button": "Wiki",
"github_button": "GitHub", "github_button": "GitHub",
@@ -246,8 +247,10 @@
"about_title": "PurrfectSnap", "about_title": "PurrfectSnap",
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!", "about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers", "about_lead_developers_title": "Lead Developers",
"about_dev_external": "ΞTΞRNAL",
"about_dev_rsr": "<RSR/>",
"about_story_title": "Our Story", "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. We received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place. We would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him. Lastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, 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_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_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 😉!",
"github_button": "GitHub", "github_button": "GitHub",
@@ -3236,7 +3239,7 @@
"username": "Username", "username": "Username",
"user_id": "User ID", "user_id": "User ID",
"posted_on": "Posted", "posted_on": "Posted",
"loading_username": "Loading", "loading_username": "Loading…",
"username_copied": "Username copied", "username_copied": "Username copied",
"user_id_copied": "User ID copied", "user_id_copied": "User ID copied",
"friend_status": "Friend status", "friend_status": "Friend status",
@@ -3639,3 +3642,4 @@
"openrouter": "OpenRouter" "openrouter": "OpenRouter"
} }
} }

View File

@@ -247,7 +247,7 @@
"about_tagline": "¡Un Módulo Xposed destinado a mejorar tu experiencia en Snapchat!", "about_tagline": "¡Un Módulo Xposed destinado a mejorar tu experiencia en Snapchat!",
"about_lead_developers_title": "Desarrolladores Principales", "about_lead_developers_title": "Desarrolladores Principales",
"about_story_title": "Nuestra Historia", "about_story_title": "Nuestra Historia",
"about_story": "PurrfectSnap fue fundado el 2 de octubre de 2025, como un fork de SnapEnhance por ΞTΞRNAL con la visión de proporcionar a los usuarios la experiencia de calidad en Snapchat que merecen. Esta aplicación solo estaba destinada a ser una actualización menor en el repositorio de SnapEnhance, pero pronto se convirtió en una aplicación separada donde los colaboradores siguieron añadiendo funciones. Luego, el desarrollador <RSR/> se unió al equipo, y esta aplicación pronto se convirtió en un gran éxito. ¡Recibimos mucho amor y apoyo y ganamos más de 1K descargas en solo dos días! Agradecemos a todos los usuarios y colaboradores; sin su apoyo, no habríamos llegado a este lugar. También nos gustaría expresar nuestro enorme agradecimiento a rhunk, el desarrollador principal de SnapEnhance, ya que sin él, esta aplicación ni siquiera existiría. Le estamos inmensamente agradecidos. Por último, nos gustaría agradecer a todos nuestros administradores, notablemente: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, que estuvieron allí con nosotros desde el principio. También nos gustaría agradecer a todos los testers, notablemente Leo & Toxic, que probaron e informaron errores continuamente. Estamos inmensamente agradecidos por su contribución.", "about_story": "PurrfectSnap fue fundado el 2 de octubre de 2025, como un fork de SnapEnhance por ΞTΞRNAL con la visión de proporcionar a los usuarios la experiencia de calidad en Snapchat que merecen. Esta aplicación solo estaba destinada a ser una actualización menor en el repositorio de SnapEnhance, pero pronto se convirtió en una aplicación separada donde los colaboradores siguieron añadiendo funciones. Luego, el desarrollador <RSR/> se unió al equipo, y esta aplicación pronto se convirtió en un gran éxito.\n\nTambién nos gustaría expresar nuestro enorme agradecimiento a rhunk, el desarrollador principal de SnapEnhance, ya que sin él, esta aplicación ni siquiera existiría. Le estamos inmensamente agradecidos.\n\n¡Recibimos mucho amor y apoyo y ganamos más de 1K descargas en solo dos días! Agradecemos a todos los usuarios y colaboradores; sin su apoyo, no habríamos llegado a este lugar.\n\nPor último, nos gustaría agradecer a todos nuestros administradores, notablemente: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, que estuvieron allí con nosotros desde el principio. También nos gustaría agradecer a todos los testers, notablemente Leo & Toxic, que probaron e informaron errores continuamente. Estamos inmensamente agradecidos por su contribución.",
"about_thanks_title": "Con amor, el equipo de PurrfectSnap", "about_thanks_title": "Con amor, el equipo de PurrfectSnap",
"about_magic_toast": "¡Toca 5 veces en esta pantalla para ver algo de magia 😉!", "about_magic_toast": "¡Toca 5 veces en esta pantalla para ver algo de magia 😉!",
"github_button": "GitHub", "github_button": "GitHub",

View File

@@ -247,7 +247,7 @@
"about_tagline": "Un module Xposed conçu pour améliorer votre expérience Snapchat !", "about_tagline": "Un module Xposed conçu pour améliorer votre expérience Snapchat !",
"about_lead_developers_title": "Développeurs principaux", "about_lead_developers_title": "Développeurs principaux",
"about_story_title": "Notre histoire", "about_story_title": "Notre histoire",
"about_story": "PurrfectSnap a été fondé le 2 octobre 2025, en tant que fork de SnapEnhance par ΞTΞRNAL avec la vision d'offrir aux utilisateurs l'expérience Snapchat de qualité qu'ils méritent. Cette application ne devait être qu'une mise à jour mineure du dépôt SnapEnhance, mais elle est rapidement devenue une application distincte où les contributeurs ont continué à ajouter des fonctionnalités. Ensuite, le développeur <RSR/> a rejoint l'équipe, et cette application est rapidement devenue un énorme succès. Nous avons reçu beaucoup d'amour et de soutien et avons gagné plus de 1000 téléchargements en seulement deux jours ! Nous remercions tous les utilisateurs et contributeurs ; sans votre soutien, nous n'aurions pas atteint ce stade. Nous tenons également à remercier chaleureusement rhunk, le développeur principal de SnapEnhance, car sans lui, cette application n'existerait même pas. Nous lui sommes immensément reconnaissants. Enfin, nous tenons à remercier tous nos administrateurs, notamment : CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, qui étaient là avec nous dès le tout début. Nous tenons également à remercier tous les testeurs, notamment Leo & Toxic, qui ont testé et signalé les bugs en continu. Nous sommes immensément reconnaissants pour votre contribution.", "about_story": "PurrfectSnap a été fondé le 2 octobre 2025, en tant que fork de SnapEnhance par ΞTΞRNAL avec la vision d'offrir aux utilisateurs l'expérience Snapchat de qualité qu'ils méritent. Cette application ne devait être qu'une mise à jour mineure du dépôt SnapEnhance, mais elle est rapidement devenue une application distincte où les contributeurs ont continué à ajouter des fonctionnalités. Ensuite, le développeur <RSR/> a rejoint l'équipe, et cette application est rapidement devenue un énorme succès.\n\nNous avons reçu beaucoup d'amour et de soutien et avons gagné plus de 1000 téléchargements en seulement deux jours ! Nous remercions tous les utilisateurs et contributeurs ; sans votre soutien, nous n'aurions pas atteint ce stade.\n\nNous tenons également à remercier chaleureusement rhunk, le développeur principal de SnapEnhance, car sans lui, cette application n'existerait même pas. Nous lui sommes immensément reconnaissants.\n\nEnfin, nous tenons à remercier tous nos administrateurs, notamment : CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, qui étaient là avec nous dès le tout début. Nous tenons également à remercier tous les testeurs, notamment Leo & Toxic, qui ont testé et signalé les bugs en continu. Nous sommes immensément reconnaissants pour votre contribution.",
"about_thanks_title": "Avec amour, l'équipe PurrfectSnap", "about_thanks_title": "Avec amour, l'équipe PurrfectSnap",
"about_magic_toast": "Appuyez 5 fois sur cet écran pour voir un peu de magie 😉 !", "about_magic_toast": "Appuyez 5 fois sur cet écran pour voir un peu de magie 😉 !",
"github_button": "GitHub", "github_button": "GitHub",