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
on:
push:
branches:
- dark-knight
paths:
- 'app/**'
- 'core/**'
- 'common/**'
- 'native/**'
- 'valdi/**'
- 'mapper/**'
- 'build.gradle.kts'
- 'gradle.properties'
- 'gradle/**'
workflow_dispatch:
inputs:
ci_upload:
description: 'Upload to CI channel'
required: false
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
job_armv8:
runs-on: ubuntu-latest
@@ -98,7 +116,8 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
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
id: upload_apk
uses: actions/upload-artifact@v4
@@ -196,10 +215,94 @@ jobs:
- name: Delete unsigned APK file and rename the signed one
run: |
find app/build/outputs/apk/armv7/debug/ -type f ! -name '*-signed*' -delete
mv app/build/outputs/apk/armv7/debug/purrfectsnap-armv7Debug-signed.apk app/build/outputs/apk/armv7/debug/purrfectsnap-${{ env.version }}-armv7-${GITHUB_SHA::7}.apk
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
id: upload_apk
uses: actions/upload-artifact@v4
with:
name: purrfectsnap-armv7-debug
path: app/build/outputs/apk/armv7/debug/*.apk
notify:
runs-on: ubuntu-latest
needs: [job_armv8, job_armv7]
if: always() && github.ref == 'refs/heads/dark-knight'
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
continue-on-error: true
with:
path: all-apks
merge-multiple: true
- name: Dispatch Modernized Build Card to Telegram
env:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_TO }}
run: |
sudo apt-get update && sudo apt-get install -y bc jq
./gradlew --configuration-cache getVersion || true
VERSION_NAME=$(cat app/build/version.txt 2>/dev/null || echo "Unknown")
if [[ "${{ needs.job_armv8.result }}" == "success" && "${{ needs.job_armv7.result }}" == "success" ]]; then
BUILD_STATUS="✅ SUCCESS"
else
BUILD_STATUS="❌ FAILED"
fi
# --- Premium Logic Port ---
GIT_HASH=$(git rev-parse --short HEAD)
AUTHOR=$(git log -1 --pretty=%an)
BRANCH_NAME="${{ github.ref_name }}"
if [[ "$BRANCH_NAME" == "main" ]]; then
BUILD_TYPE="💎 STABLE RELEASE"
else
BUILD_TYPE="🧪 PRE-RELEASE (BETA)"
fi
CHANGELOG=$(git log -n 5 --pretty=format:"• %s" | sed 's/&/\&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.*
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) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"],
title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
@@ -140,11 +140,20 @@ class MainActivity : ComponentActivity() {
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
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
}
// 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()
routes.bottomPadding = bottomPadding
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.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -72,6 +73,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -122,22 +124,36 @@ class Navigation(
) {
private val translation by lazy { context.translation.getCategory("manager.navigation") }
var openBottomBarCustomization by mutableStateOf(false)
var globalScrollOffset by mutableIntStateOf(0)
@Composable
fun TopBar() {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = remember(navBackStackEntry) { routes.getCurrentRoute(navBackStackEntry) }
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) {
currentRoute?.let { !it.routeInfo.primary || it.routeInfo.childIds.contains(routes.currentDestination) } == true
}
TopAppBar(
modifier = Modifier.height(headerHeight),
title = {
currentRoute?.apply {
title?.invoke() ?: routeInfo.translatedKey?.value?.let {
Text(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = Modifier.graphicsLayer {
// 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")
Box(
modifier = Modifier
.graphicsLayer { alpha = backButtonAnimation }
.graphicsLayer {
alpha = backButtonAnimation
scaleX = 1f - (focusFactor * 0.1f)
scaleY = 1f - (focusFactor * 0.1f)
}
.width(lerp(0.dp, 48.dp, backButtonAnimation))
.height(48.dp)
) {
@@ -177,6 +197,13 @@ class Navigation(
listOf(routes.tasks, routes.features, routes.home, routes.social, routes.scripting, routes.friendTracker)
}
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 defaultOrder = remember { listOf("tasks", "features", "home", "social", "scripts") }
fun loadSelected(): List<String> {
@@ -233,11 +260,20 @@ class Navigation(
val animatedBarWidth by animateDpAsState(targetValue = targetBarWidth ?: 0.dp, label = "barWidth")
Surface(
shape = barShape,
color = Color.Transparent,
color = Color.White.copy(alpha = 0.08f), // Restored frosted glass harmony
contentColor = MaterialTheme.colorScheme.onSurface,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.9f),
PurrfectPalette.glowSecondary.copy(alpha = 0.85f)
)
)
),
modifier = Modifier
.then(if (targetBarWidth != null) Modifier.width(animatedBarWidth) else Modifier.fillMaxWidth())
.height(barHeight)
.drawBehind {
val radius = size.width * 0.62f
drawCircle(
@@ -263,7 +299,7 @@ class Navigation(
Box(
Modifier
.fillMaxWidth()
.height(82.dp)
.height(barHeight)
.clip(barShape)
.background(PurrfectPalette.cardOverlay)
.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) }
val itemCount = selectedRoutes.size.coerceAtLeast(1)
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -369,7 +405,7 @@ class Navigation(
.fillMaxHeight()
.width(indicatorWidth.coerceAtLeast(0.dp))
.offset(x = offsetX)
.padding(vertical = 10.dp, horizontal = 2.dp)
.padding(vertical = lerp(10.dp, 8.dp, focusFactor), horizontal = 2.dp)
.graphicsLayer { scaleX = scaleXAnim; scaleY = scaleYAnim }
) {
Box(
@@ -430,7 +466,10 @@ class Navigation(
contentDescription = null,
modifier = Modifier
.size(22.dp + 2.dp * selectionProgress)
.graphicsLayer { alpha = 0.65f + 0.35f * selectionProgress }
.graphicsLayer {
alpha = 0.65f + 0.35f * selectionProgress
translationY = iconTranslationY.toPx()
}
)
},
label = {
@@ -441,11 +480,15 @@ class Navigation(
textAlign = TextAlign.Center,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.6f + 0.4f * selectionProgress),
color = Color.White.copy(alpha = (0.6f + 0.4f * selectionProgress) * labelAlpha),
maxLines = if (isLong) 2 else 1,
overflow = if (isLong) TextOverflow.Ellipsis else TextOverflow.Clip,
softWrap = isLong,
modifier = if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally)
modifier = (if (isLong) Modifier.widthIn(max = 90.dp).wrapContentWidth(Alignment.CenterHorizontally) else Modifier.wrapContentWidth(Alignment.CenterHorizontally))
.graphicsLayer {
alpha = labelAlpha
translationY = (-10 * focusFactor).dp.toPx() // Fall into icon
}
)
},
selected = isSelected,

View File

@@ -87,12 +87,12 @@ object Updater {
}.getOrNull()
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}")
JsonParser.parseString(it.body?.string()).asJsonObject
}
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")
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)
}.getOrNull()
private val cache = mutableMapOf<Channel, LatestRelease?>()
private val cache = mutableMapOf<Channel, Pair<Long, LatestRelease?>>()
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
} else {
fetchLatestRelease(channel)
}
val cached = cache[channel]
// Use 24-hour TTL (Time To Live) for cache to optimize API calls
if (cached != null && (System.currentTimeMillis() - cached.first) < 24 * 60 * 60 * 1000) {
return cached.second
}
val 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 androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,7 +38,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -157,8 +157,8 @@ class FileImportsRoot: Routes.Route() {
.padding(horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(
top = topBarHeight + 8.dp,
bottom = routes.bottomPadding + 16.dp
top = topBarHeight,
bottom = routes.bottomPadding
)
) {
item {
@@ -270,9 +270,7 @@ class FileImportsRoot: Routes.Route() {
title = titleText ?: translation["import_file_button"],
subtitle = null,
onBack = { routes.navController.popBackStack() },
modifier = Modifier.onGloballyPositioned {
topBarHeight = with(density) { it.size.height.toDp() }
}
modifier = Modifier.headerHeightTracker { topBarHeight = it }
)
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
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.Color
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.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -289,64 +292,137 @@ class LoggerHistoryRoot : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
FloatingTopBar(
title = context.translation["manager.routes.logger_history"] ?: "Logger History",
onBack = { routes.navController.popBackStack() }
)
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, bottom = 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))
) {
Column(
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
Spacer(modifier = Modifier.height(controlsHeight))
var expanded by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
.fillMaxWidth()
.padding(top = 4.dp, bottom = 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(
expanded = expanded,
onExpandedChange = { expanded = it },
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
fun formatConversationInfo(conversationInfo: ConversationInfo?): String? {
if (conversationInfo == null) return null
return conversationInfo.groupTitle?.let {
translation.format("list_group_format", "name" to it)
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
} ?: context.database.findFriend(conversationInfo.conversationId)?.let {
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
} ?: conversationInfo.usernames.firstOrNull()?.let {
translation.format("list_friend_format", "name" to "($it)")
return conversationInfo.groupTitle?.let {
translation.format("list_group_format", "name" to it)
} ?: conversationInfo.usernames.takeIf { it.size > 1 }?.let {
translation.format("list_friend_format", "name" to ("(" + it.joinToString(", ") + ")"))
} ?: context.database.findFriend(conversationInfo.conversationId)?.let {
translation.format("list_friend_format", "name" to "(" + (conversationInfo.usernames + listOf(it.mutableUsername)).toSet().joinToString(", ") + ")")
} ?: conversationInfo.usernames.firstOrNull()?.let {
translation.format("list_friend_format", "name" to "($it)")
}
}
}
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let {
conversationInfoCache.getOrPut(it) {
formatConversationInfo(loggerWrapper.getConversationInfo(it))
val selectedConversationInfo by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(selectedConversation)) {
selectedConversation?.let {
conversationInfoCache.getOrPut(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(
value = selectedConversationInfo ?: translation["select_conversation_placeholder"],
onValueChange = {},
readOnly = true,
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
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(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
@@ -358,160 +434,99 @@ class LoggerHistoryRoot : Routes.Route() {
)
)
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
)
})
}
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)
)
)
}
}
}
OutlinedTextField(
value = stringFilter,
onValueChange = { stringFilter = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
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
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(
state = listState,
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, color = Color.White)
} else {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(
modifier = Modifier
.height(20.dp)
.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) {
withContext(Dispatchers.IO) {
val newMessages = loggerWrapper.fetchMessages(
selectedConversation ?: return@withContext,
lastFetchMessageTimestamp,
30,
reverseOrder
) { messageData ->
if (stringFilter.isEmpty()) return@fetchMessages true
var isMatch = false
decodeMessage(messageData) { contentType, messageReader, _ ->
if (contentType == ContentType.CHAT) {
val content = messageReader.getString(2, 1) ?: return@decodeMessage
isMatch = content.contains(stringFilter, ignoreCase = true)
LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) {
withContext(Dispatchers.IO) {
val newMessages = loggerWrapper.fetchMessages(
selectedConversation ?: return@withContext,
lastFetchMessageTimestamp,
30,
reverseOrder
) { messageData ->
if (stringFilter.isEmpty()) return@fetchMessages true
var isMatch = false
decodeMessage(messageData) { contentType, messageReader, _ ->
if (contentType == ContentType.CHAT) {
val content = messageReader.getString(2, 1) ?: return@decodeMessage
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)
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.lazy.LazyColumn
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.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Public
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 me.eternal.purrfectsnap.ui.util.headerHeightTracker
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.removeRepo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
class ManageReposSection: Routes.Route() {
@@ -165,44 +177,74 @@ class ManageReposSection: Routes.Route() {
val repositories = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateDispatcher) {
context.database.getRepositories(repoType)
}
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + routes.bottomPadding),
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
routes.navigation?.globalScrollOffset = offset
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
item {
if (repositories.isEmpty()) {
Text(translation["no_repos_added"], modifier = Modifier
.padding(16.dp)
.fillMaxWidth(), fontSize = 15.sp, fontWeight = FontWeight.Light, textAlign = TextAlign.Center)
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(start = 8.dp, top = controlsHeight, end = 8.dp, bottom = routes.bottomPadding),
) {
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 ->
ElevatedCard(onClick = {
context.androidContext.copyToClipboard(url)
}, modifier = Modifier.animateContentSize()) {
Row(
items(repositories) { url ->
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
.padding(horizontal = 10.dp, vertical = 6.dp)
.clickable { context.androidContext.copyToClipboard(url) },
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)
Text(text = url, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 4, fontSize = 15.sp, lineHeight = 15.sp)
Button(
onClick = {
context.database.removeRepo(repoType, url)
coroutineScope.launch {
updateDispatcher.dispatch()
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
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.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,6 +41,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import java.io.File
import java.util.UUID
import kotlin.math.absoluteValue
@@ -105,7 +108,6 @@ class TasksRootSection : Routes.Route() {
runCatching {
pendingTask.updateProgress("Copying ${documentFile.name}")
context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream ->
//copy with progress
val length = documentFile.length().toFloat()
tempFile.outputStream().use { outputStream ->
val buffer = ByteArray(16 * 1024)
@@ -301,7 +303,7 @@ class TasksRootSection : Routes.Route() {
)
Column {
Text(
text = context.translation["delete_files_option"],
text = context.translation["delete_files_option"] ?: "Delete Files",
color = Color.White,
fontWeight = FontWeight.SemiBold
)
@@ -389,59 +391,7 @@ class TasksRootSection : Routes.Route() {
}
}
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 }
)
}
}
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
@Composable
private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) {
@@ -613,7 +563,7 @@ class TasksRootSection : Routes.Route() {
} else {
when {
!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("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
@@ -674,11 +624,9 @@ class TasksRootSection : Routes.Route() {
modifier = Modifier.weight(1f),
) {
if (task.type == TaskType.SCHEDULED_SEND) {
// Professional design for scheduled send tasks
Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
// Feature title
Text(
context.translation.getOrNull("scheduled_send_title") ?: "Scheduled Snaps",
style = MaterialTheme.typography.labelMedium,
@@ -686,7 +634,6 @@ class TasksRootSection : Routes.Route() {
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
// Scheduled time without icon
Text(
task.title,
style = MaterialTheme.typography.titleMedium,
@@ -694,7 +641,6 @@ class TasksRootSection : Routes.Route() {
color = Color.White
)
// Recipients with icon
task.author?.takeIf { it != "null" }?.let { recipients ->
Row(
verticalAlignment = Alignment.Top,
@@ -844,6 +790,14 @@ class TasksRootSection : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = {
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()
recentTasks = remember { mutableStateListOf() }
var lastFetchedTaskId by remember { mutableStateOf(null as Long?) }
@@ -883,151 +837,98 @@ class TasksRootSection : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
Column(modifier = Modifier.fillMaxSize()) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
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)
)
)
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.tasks"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
"active" to activeTasks.size.toString(),
"recent" to recentTasks.size.toString()
)
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
},
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 = {
} else {
translation.format(
"summary_idle",
"recent" to recentTasks.size.toString()
)
}
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = context.translation["manager.routes.tasks"] ?: "Tasks",
subtitle = subtitle,
scrollOffset = if (scrollState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else scrollState.firstVisibleItemScrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
shape = RoundedCornerShape(50),
color = Color.White.copy(alpha = 0.1f),
modifier = Modifier
.padding(end = 8.dp)
.clickable {
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.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(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
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 = translation.format("running_count", "count" to activeTasks.size.toString()),
translation["merge_button"],
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(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = controlsHeight,
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
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"])
}
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(recentTasks, key = { it.hash }) { task ->
TaskCard(modifier = Modifier.fillMaxWidth(), task)
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
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)
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
@@ -1045,8 +946,8 @@ class TasksRootSection : Routes.Route() {
TaskDangerDialog(
visible = showConfirmDialog,
title = titleText,
message = messageText,
title = titleText ?: "",
message = messageText ?: "",
showDeleteFiles = isSelection,
deleteFilesChecked = alsoDeleteFiles,
onToggleDeleteFiles = { alsoDeleteFiles = it },

View File

@@ -1,45 +1,24 @@
package me.eternal.purrfectsnap.ui.manager.pages.features
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.KeyboardArrowDown
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.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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.sp
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.saveFile
import me.eternal.purrfectsnap.storage.getLocationCoordinates
import org.json.JSONArray
@@ -147,189 +128,102 @@ class ConfigExportSummaryScreen : Routes.Route() {
parser.parse(context.config.exportToString(exportSensitiveData, includeSavedLocations, savedLocations))
}
val expandedState = remember { mutableStateMapOf<String, Boolean>() }
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Bottom))
.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(
items(featuresByCategory.toList()) { (category, features) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
Surface(
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.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)
)
.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.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
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
)
}
)
) {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
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<*> -> {
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) {
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(
modifier = Modifier.padding(start = (feature.indentation * 16).dp),
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = feature.name,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Column(
modifier = Modifier.padding(start = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
parsedValue.forEachIndexed { itemIndex, item ->
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
NumberBubble(itemIndex + 1)
Text(
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 -> {
Column(
@@ -354,7 +248,6 @@ class ConfigExportSummaryScreen : Routes.Route() {
if (index < features.size - 1) {
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
import me.eternal.purrfectsnap.ui.util.Motion
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
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.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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.getLocationCoordinates
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import org.json.JSONArray
import org.json.JSONObject
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
}
/**
* Imports saved locations from JSON array into database with de-duplication.
* Only adds locations that don't already exist (within coordinate tolerance).
*/
private fun importSavedLocations(locationsArray: com.google.gson.JsonArray) {
val existingLocations = context.database.getLocationCoordinates()
@@ -87,14 +66,12 @@ class ConfigImportConfirmationScreen : Routes.Route() {
val longitude = locationObj.get("longitude")?.asDouble ?: continue
val radius = locationObj.get("radius")?.asDouble ?: 100.0
// Check for existing location with similar coordinates (de-duplication)
val existingMatch = existingLocations.find { existing ->
abs(existing.latitude - latitude) < COORDINATE_TOLERANCE &&
abs(existing.longitude - longitude) < COORDINATE_TOLERANCE
}
if (existingMatch == null) {
// No duplicate found, add as new location
val newLocation = LocationCoordinates().apply {
this.name = name
this.latitude = latitude
@@ -103,7 +80,6 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
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 importLabel = translation["confirm_button"]
val listState = rememberLazyListState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
Column(
LazyColumn(
modifier = Modifier
.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) ->
val isExpanded = expandedState[category] ?: false
val rotationState by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f)
@@ -439,15 +330,46 @@ class ConfigImportConfirmationScreen : Routes.Route() {
}
if (index < features.size - 1) {
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.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.FloatingTopBar
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.Actions
@@ -223,25 +223,17 @@ class ManageRuleFeature : Routes.Route() {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val density = LocalDensity.current
var topBarHeight by remember { mutableStateOf(96.dp) }
FloatingTopBar(
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberScrollState()),
val scrollState = rememberScrollState()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
@@ -397,6 +389,13 @@ class ManageRuleFeature : Routes.Route() {
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
import android.os.SystemClock
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,6 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -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.components.FloatingTopBar
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() {
override val translation by lazy { context.translation.getCategory("manager.sections.home_about") }
override val content: @Composable (NavBackStackEntry) -> Unit = {
val avenirNext = remember {
FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium))
@@ -67,9 +50,7 @@ class HomeAbout : Routes.Route() {
val scrollState = rememberScrollState()
val aboutStory = remember { translation["about_story"] }
val pagePadding = 16.dp
val bottomPadding = routes.bottomPadding +
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
24.dp
val bottomPadding = routes.bottomPadding
val tapSource = remember { MutableInteractionSource() }
val tapTimeoutMs = 1500L
val tapCount = remember { mutableIntStateOf(0) }
@@ -79,24 +60,23 @@ class HomeAbout : Routes.Route() {
context.shortToast(translation["about_magic_toast"])
}
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
var controlsHeight by remember { mutableStateOf(100.dp) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(bottom = bottomPadding)
.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(
modifier = Modifier
.padding(horizontal = pagePadding)
@@ -156,13 +136,13 @@ class HomeAbout : Routes.Route() {
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(
name = "ΞTΞRNAL",
name = translation["about_dev_external"],
imageRes = R.drawable.pfp_external,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
)
DeveloperCard(
name = "<RSR/>",
name = translation["about_dev_rsr"],
imageRes = R.drawable.pfp_rsr,
avenirNext = avenirNext,
modifier = Modifier.weight(1f)
@@ -176,7 +156,8 @@ class HomeAbout : Routes.Route() {
Surface(
modifier = Modifier
.padding(horizontal = pagePadding)
.fillMaxWidth(),
.fillMaxWidth()
.widthIn(max = 500.dp),
shape = RoundedCornerShape(26.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)),
@@ -186,20 +167,24 @@ class HomeAbout : Routes.Route() {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay)
.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(horizontal = 24.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = translation["about_story_title"],
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
color = Color.White,
textAlign = TextAlign.Center
)
Text(
text = aboutStory,
fontSize = 14.sp,
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(
modifier = Modifier
.size(82.dp)
.size(76.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(2.dp, imageRing, CircleShape)
.border(1.5.dp, imageRing, CircleShape)
) {
Image(
painter = painterResource(id = imageRes),
@@ -328,16 +318,17 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize()
)
}
Text(
PurrfectMarqueeText(
text = name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = avenirNext
),
color = Color.White,
fontFamily = avenirNext,
maxLines = 1,
overflow = TextOverflow.Ellipsis
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}

View File

@@ -64,8 +64,11 @@ import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.common.logger.LogChannel
import me.eternal.purrfectsnap.common.logger.LogLevel
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
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.pullRefresh
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
class HomeLogs : Routes.Route() {
private val logListState by lazy { LazyListState(0) }
private val logListState = LazyListState()
private lateinit var activityLauncherHelper: ActivityLauncherHelper
private val externalRefreshTick = mutableStateOf(0)
private val externalRefreshTick = mutableIntStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
}
@@ -109,18 +112,19 @@ class HomeLogs : Routes.Route() {
override val topBarActions: @Composable (RowScope.() -> Unit) = {}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val coroutineScope = rememberCoroutineScope()
var controlsHeight by remember { mutableStateOf(100.dp) }
val composeContext = LocalContext.current
var logReader by remember { mutableStateOf<LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
mainExecutor.execute {
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
@@ -141,8 +145,7 @@ class HomeLogs : Routes.Route() {
}
delay(220)
if (visibleLogs.isNotEmpty()) {
val targetIndex = (visibleLogs.size - 1).coerceAtLeast(0)
logListState.scrollToItem(targetIndex)
logListState.scrollToItem((visibleLogs.size - 1).coerceAtLeast(0))
}
isRefreshing = false
}
@@ -167,49 +170,98 @@ class HomeLogs : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
LogsFloatingBar(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
refreshLogs()
},
onExport = { exportLogs() },
onClear = { clearLogsAndReload() }
)
Spacer(modifier = Modifier.height(12.dp))
Surface(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = 12.dp,
bottom = routes.bottomPadding + 22.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
var showDropDown by remember { mutableStateOf(false) }
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (visibleLogs.isEmpty() && logReader != null) {
EmptyLogsState()
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
state = logListState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 8.dp,
end = 8.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 12.dp
)
) {
items(visibleLogs, key = { it.hashCode() }) { line ->
LogEntryCard(line = line, composeContext = composeContext)
}
}
}
}
FloatingTopBar(
title = context.translation["manager.routes.home_logs"] ?: "Logs",
onBack = { routes.navController.popBackStack() },
scrollOffset = if (logListState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else logListState.firstVisibleItemScrollOffset,
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(
refreshing = isRefreshing,
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
private fun EmptyLogsState() {
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.components.AestheticDialog
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.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -203,7 +204,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween,
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(
checked = value,
onCheckedChange = null,
@@ -235,7 +236,7 @@ class HomeSettings : Routes.Route() {
horizontalArrangement = Arrangement.SpaceBetween,
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(
checked = value,
onCheckedChange = null,
@@ -280,8 +281,8 @@ class HomeSettings : Routes.Route() {
Column(
modifier = Modifier.weight(1f),
) {
Text(text = context.translation["actions.$key.name"], fontSize = 16.sp, fontWeight = FontWeight.Bold, lineHeight = 20.sp)
context.translation.getOrNull("actions.$key.description")?.let { Text(text = it, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 15.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, color = PurrfectPalette.textSecondary) }
}
IconButton(onClick = { takeAction() },
modifier = Modifier.padding(end = 2.dp)
@@ -289,7 +290,8 @@ class HomeSettings : Routes.Route() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
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 scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
LaunchedEffect(scrollState.value) {
routes.navigation?.globalScrollOffset = scrollState.value
}
val positiveLabel = context.translation["button.positive"]
val negativeLabel = context.translation["button.negative"]
val importLabel = context.translation["button.import"]
@@ -372,109 +379,27 @@ class HomeSettings : Routes.Route() {
}
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
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
)
}
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(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(top = controlsHeight, bottom = routes.bottomPadding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.fillMaxWidth()
.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 {
RowTitle(title = translation["actions_title"])
EnumAction.entries.forEach { enumAction ->
@@ -486,56 +411,54 @@ class HomeSettings : Routes.Route() {
GlassCard {
RowTitle(title = translation["ui_settings_title"])
ShiftedRow {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"])
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"])
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["haptic_feedback_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var hapticFeedbackEnabled by remember { mutableStateOf(context.config.root.global.uiSettings.hapticFeedback.getNullable() ?: true) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = hapticFeedbackEnabled,
onCheckedChange = {
if (it) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
hapticFeedbackEnabled = it
context.config.root.global.uiSettings.hapticFeedback.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["use_system_toasts_label"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
var useSystemToasts by remember { mutableStateOf(context.config.root.global.uiSettings.useSystemToasts.getNullable() ?: false) }
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = useSystemToasts,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
useSystemToasts = it
context.config.root.global.uiSettings.useSystemToasts.set(it)
context.config.writeConfig()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
}
}
@@ -546,31 +469,29 @@ class HomeSettings : Routes.Route() {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"])
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 55.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = translation["auto_update_check"], color = Color.White, modifier = Modifier.padding(start = 26.dp, end = 16.dp))
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = autoUpdateCheck,
onCheckedChange = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
autoUpdateCheck = it
context.config.root.global.updateSettings.autoUpdateCheck.set(it)
context.config.writeConfig()
scheduleUpdateCheck()
},
modifier = Modifier.padding(end = 26.dp),
colors = purrfectSwitchColors()
)
}
AnimatedVisibility(visible = autoUpdateCheck) {
val spacingModifier = Modifier
@@ -628,12 +549,14 @@ class HomeSettings : Routes.Route() {
text = translation["reset_setup_action"],
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
lineHeight = 20.sp,
color = Color.White
)
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
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)),
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))
Text(translation["clear_button"])
}
}
ShiftedRow {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PremiumPreferenceToggle(
context.sharedPreferences,
key = "test_mode",
text = translation["test_mode_label"],
defaultValue = true,
confirmDisableTitle = translation["purr_aura_disable_title"],
confirmDisableText = translation["purr_aura_disable_text"]
)
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding + 12.dp))
}
}
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.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient
class ManageScriptReposSection : Routes.Route() {
@@ -296,10 +297,7 @@ class ManageScriptReposSection : Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
if (repositories.isEmpty()) {
Box(
@@ -319,9 +317,9 @@ class ManageScriptReposSection : Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight + 12.dp,
top = topBarHeight,
end = 12.dp,
bottom = 18.dp + routes.bottomPadding
bottom = routes.bottomPadding
),
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.ui.AsyncUpdateDispatcher
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.util.ktx.getUrlFromClipboard
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.AestheticEmptyState
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.Dialog
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") }
private lateinit var activityLauncherHelper: ActivityLauncherHelper
val reloadDispatcher = AsyncUpdateDispatcher(updateOnFirstComposition = false)
private var selectedTab by mutableStateOf(0)
override val init: () -> Unit = {
activityLauncherHelper = ActivityLauncherHelper(context.activity!!)
@@ -140,7 +146,7 @@ class ScriptingRootSection : Routes.Route() {
return@launch
}.onFailure {
context.log.error("Failed to import script", it)
context.shortToast(translation.format("import_failed", "message" to (it.message ?: context.translation["common.unknown"])))
context.shortToast(translation.format("import_failed", "message" to (it.message ?: "Unknown")))
}
isLoading = false
}
@@ -431,7 +437,7 @@ class ScriptingRootSection : Routes.Route() {
@Composable
private fun SelectFolderButton(onClick: () -> Unit) {
val label = translation["select_folder_button"]
val label = translation.getOrNull("select_folder_button") ?: "Select folder"
Box(
modifier = Modifier
.fillMaxWidth()
@@ -439,13 +445,13 @@ class ScriptingRootSection : Routes.Route() {
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(78.dp),
modifier = Modifier.size(68.dp),
shape = CircleShape,
color = PurrfectPalette.cardOverlayColor.copy(alpha = 0.9f),
color = Color.White.copy(alpha = 0.1f),
tonalElevation = 0.dp,
shadowElevation = 14.dp,
shadowElevation = 10.dp,
border = BorderStroke(
1.5.dp,
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.7f),
@@ -456,18 +462,17 @@ class ScriptingRootSection : Routes.Route() {
) {
Box(
modifier = Modifier
.padding(6.dp)
.size(66.dp)
.fillMaxSize()
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.42f),
PurrfectPalette.glowSecondary.copy(alpha = 0.34f)
PurrfectPalette.glowPrimary.copy(alpha = 0.335f),
PurrfectPalette.glowSecondary.copy(alpha = 0.25f),
Color.Transparent
)
)
)
.border(1.dp, Color.White.copy(alpha = 0.14f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
@@ -475,7 +480,7 @@ class ScriptingRootSection : Routes.Route() {
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = label,
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 = {
val scriptingFolder by rememberAsyncMutableState(
defaultValue = null,
@@ -508,10 +514,14 @@ class ScriptingRootSection : Routes.Route() {
val tabTitles = listOf(translation["installed_scripts_tab"], translation["catalog_tab"])
var showImportDialog 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) {
if (scriptingFolder == null && selectedTab != 0) {
selectedTab = 0
if (scriptingFolder == null && pagerState.currentPage != 0) {
pagerState.scrollToPage(0)
}
}
@@ -532,12 +542,12 @@ class ScriptingRootSection : Routes.Route() {
) {
ScriptingHeader(
titles = tabTitles,
selectedTab = selectedTab,
selectedTab = pagerState.currentPage,
onTabSelected = { index ->
if (index == 1 && scriptingFolder == null) {
showToast = true
} else {
selectedTab = index
coroutineScope.launch { pagerState.animateScrollToPage(index) }
}
},
onImport = {
@@ -562,15 +572,99 @@ class ScriptingRootSection : Routes.Route() {
context.translation["toast_open_link_failed"]
)
},
folderSelected = scriptingFolder != null
folderSelected = scriptingFolder != null,
onPositioned = { controlsHeight = it }
)
Spacer(Modifier.height(12.dp))
when (selectedTab) {
0 -> InstalledTabContent(
scriptingFolder = scriptingFolder
)
1 -> CatalogTabContent(
scriptingFolder = scriptingFolder
androidx.compose.foundation.pager.HorizontalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState,
userScrollEnabled = scriptingFolder != null
) { page ->
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
private fun InstalledTabContent(
scriptingFolder: DocumentFile?
scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) {
val scriptModules by rememberAsyncMutableState(
defaultValue = emptyList(),
@@ -605,229 +700,144 @@ class ScriptingRootSection : Routes.Route() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
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(
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()) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState),
contentPadding = PaddingValues(bottom = routes.bottomPadding + 28.dp, start = 8.dp, end = 8.dp, top = 12.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
if (scriptingFolder == null && !refreshing) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 260.dp),
contentAlignment = Alignment.Center
) {
Surface(
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)
)
)
item {
if (scriptingFolder == null && !refreshing) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
contentAlignment = Alignment.Center
) {
Surface(
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()
.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(
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(220.dp),
contentAlignment = Alignment.Center
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
AestheticEmptyState(
icon = Icons.Default.DataObject,
title = translation["no_scripts_found_title"],
subtitle = translation["use_catalog_to_add_scripts"],
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
.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"] ?: "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 ->
ModuleItem(scriptModules[index])
}
}
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(
} else if (scriptModules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
.height(220.dp),
contentAlignment = Alignment.Center
) {
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
.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
)
}
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
}
}
}
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
private fun CatalogTabContent(
scriptingFolder: DocumentFile?
scriptingFolder: DocumentFile?,
controlsHeight: androidx.compose.ui.unit.Dp
) {
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
) {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
if (scriptingFolder == null) {
AestheticEmptyState(
icon = Icons.Default.FolderOpen,
title = translation["no_scripts_folder_selected_title"],
subtitle = translation["select_scripts_folder_toast"],
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
} else {
ScriptCatalog(this@ScriptingRootSection)
}
if (scriptingFolder == null) {
AestheticEmptyState(
icon = Icons.Default.FolderOpen,
title = translation["no_scripts_folder_selected_title"] ?: "No scripts folder selected",
subtitle = translation["select_scripts_folder_toast"] ?: "Please select a folder to store scripts.",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 22.dp)
)
} else {
ScriptCatalog(this@ScriptingRootSection)
}
}
}
@@ -841,71 +851,51 @@ class ScriptingRootSection : Routes.Route() {
onOpenFolder: () -> Unit,
onManageRepos: () -> Unit,
onDocs: () -> Unit,
folderSelected: Boolean
folderSelected: Boolean,
onPositioned: (androidx.compose.ui.unit.Dp) -> Unit = {}
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
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)
)
)
)
) {
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
)
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val shrinkThreshold = 300f
val focusFactor = (scrollOffset / shrinkThreshold).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
Column(modifier = Modifier.headerHeightTracker(onPositioned), verticalArrangement = Arrangement.spacedBy(12.dp)) {
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.scripts"] ?: "Scripts",
subtitle = if (selectedTab == 0) translation["installed_scripts_tab"] else translation["catalog_tab"],
scrollOffset = scrollOffset,
actions = {
IconButton(onClick = onDocs) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onDocs) {
Icon(Icons.Default.CollectionsBookmark, contentDescription = translation["documentation_button"], tint = Color.White)
}
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))
}
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,
onTabSelected = onTabSelected
)
)
if (tabSwitcherAlpha > 0.05f) {
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
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.FloatingTopBar
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.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -118,16 +118,13 @@ class ManageScope: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 8.dp)
.padding(top = topBarHeight)
.verticalScroll(rememberScrollState())
) {
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.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -59,6 +58,7 @@ import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -767,16 +767,13 @@ class MessagingPreview: Routes.Route() {
},
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 6.dp)
.padding(top = topBarHeight)
.padding(horizontal = 14.dp)
) {
if (hasBridgeError) {

View File

@@ -7,6 +7,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.Color
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.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
@@ -62,7 +67,8 @@ class SocialRootSection : Routes.Route() {
private fun ScopeList(
scope: SocialScope,
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 list = when (scope) {
@@ -70,10 +76,17 @@ class SocialRootSection : Routes.Route() {
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(
modifier = Modifier
.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)
) {
//check if scope list is empty
@@ -194,6 +207,8 @@ class SocialRootSection : Routes.Route() {
val pagerState = rememberPagerState { titles.size }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
LaunchedEffect(Unit) {
updateScopeLists()
@@ -234,10 +249,11 @@ class SocialRootSection : Routes.Route() {
onSearchToggle = {
searchActive = !searchActive
if (!searchActive) searchQuery = ""
}
},
onPositioned = { controlsHeight = it }
)
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 searchBorder = Brush.linearGradient(
listOf(
@@ -307,8 +323,8 @@ class SocialRootSection : Routes.Route() {
state = pagerState
) { page ->
when (page) {
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups)
0 -> ScopeList(SocialScope.FRIEND, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
1 -> ScopeList(SocialScope.GROUP, filteredFriends, filteredGroups, controlsHeight = controlsHeight)
}
}
}
@@ -375,7 +391,7 @@ class SocialRootSection : Routes.Route() {
fontSize = 15.sp
)
Text(
text = translation["groups_tab"],
text = translation["groups_tab"] ?: "Groups",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
@@ -485,68 +501,50 @@ class SocialRootSection : Routes.Route() {
friendCount: Int,
groupCount: Int,
searchActive: Boolean,
onSearchToggle: () -> Unit
onSearchToggle: () -> Unit,
onPositioned: (Dp) -> Unit = {}
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
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)
)
)
)
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val tabSwitcherAlpha = (1f - (focusFactor * 2.5f)).coerceIn(0f, 1f)
Column(
modifier = Modifier.headerHeightTracker(onPositioned),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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.social"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar(
title = translation["manager.routes.social"] ?: "Social",
subtitle = if (pagerState.currentPage == 0) translation["friends_tab"] else translation["groups_tab"],
scrollOffset = scrollOffset,
actions = {
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
)
}
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,
onTabSelected = onTabSelected
)
)
if (tabSwitcherAlpha > 0.05f) {
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
)
Text(
text = translation["social_empty_hint"],
text = translation["social_empty_hint"] ?: "Tap the + button to sync friends or groups.",
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)

View File

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

View File

@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.RowScope
@@ -32,7 +33,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
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.FloatingTopBar
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.Request
@@ -69,7 +71,10 @@ class FriendTrackerCatalog : Routes.Route() {
override val translation by lazy { context.translation.getCategory("manager.friend_tracker_catalog") }
@Composable
private fun AvailableRulesTab(topBarHeight: Dp) {
private fun AvailableRulesTab(
controlsHeight: androidx.compose.ui.unit.Dp,
onScrollOffsetChanged: (Int) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val okHttpClient = remember { OkHttpClient() }
val gson = remember { context.gson }
@@ -78,7 +83,6 @@ class FriendTrackerCatalog : Routes.Route() {
var repoIndexes by remember { mutableStateOf<Map<String, FriendTrackerRepoManifest>>(emptyMap()) }
var isLoading by remember { mutableStateOf(false) }
// Ticks whenever a rule import happens so isImported values recompute
var importTick by remember { mutableStateOf(0) }
fun refreshIndexes() {
@@ -93,7 +97,7 @@ class FriendTrackerCatalog : Routes.Route() {
repos.forEach { repoRoot ->
val indexUrl = if (repoRoot.endsWith("/")) "${repoRoot}index.json" else "$repoRoot/index.json"
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 ->
if (response.isSuccessful) {
response.body?.charStream()?.let { reader ->
@@ -160,14 +164,14 @@ class FriendTrackerCatalog : Routes.Route() {
coroutineScope.launch(Dispatchers.IO) {
val rawUrl = if (repoUrl.endsWith("/")) repoUrl + entry.path else repoUrl + "/" + entry.path
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 ->
if (!response.isSuccessful) {
withContext(Dispatchers.Main) { context.shortToast(translation.format("download_failed", "code" to response.code.toString())) }
return@use
}
val content = response.body?.string()
if (content != null) { // ktlint-disable no-multi-spaces
if (content != null) {
withContext(Dispatchers.Main) {
routes.friendTrackerConfigJsonForImport = content
routes.friendTrackerConfigImport.navigate()
@@ -182,27 +186,21 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
if (repositories.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = translation["no_repos_added"],
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
}
} else {
val listState = rememberLazyListState()
LaunchedEffect(listState.firstVisibleItemScrollOffset, listState.firstVisibleItemIndex) {
val offset = if (listState.firstVisibleItemIndex > 0) Motion.HEADER_MORPH_THRESHOLD.toInt() else listState.firstVisibleItemScrollOffset
onScrollOffsetChanged(offset)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
start = 8.dp,
top = topBarHeight + 12.dp,
top = controlsHeight,
end = 8.dp,
bottom = 8.dp + routes.bottomPadding
bottom = routes.bottomPadding
)
) {
item {
@@ -233,7 +231,6 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
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 exists = withContext(Dispatchers.IO) {
context.database.getTrackerRuleByName(entry.name) != null
@@ -327,35 +324,34 @@ class FriendTrackerCatalog : Routes.Route() {
}
}
}
}
}
override val content: @Composable (NavBackStackEntry) -> Unit = {
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
var topBarHeight by remember { mutableStateOf(statusBarTopPadding + 96.dp) }
var scrollOffset by remember { mutableIntStateOf(0) }
val density = androidx.compose.ui.platform.LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
Box(
modifier = Modifier
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
AvailableRulesTab(
controlsHeight = controlsHeight,
onScrollOffsetChanged = { scrollOffset = it }
)
FloatingTopBar(
title = translation["title"],
onBack = { routes.navController.popBackStack() },
scrollOffset = scrollOffset,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
IconButton(onClick = { routes.manageFriendTrackerRepos.navigate() }) {
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoGraph
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.ui.window.Dialog
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -53,12 +47,12 @@ import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
import me.eternal.purrfectsnap.ui.util.openFile
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.pagerTabIndicatorOffset
@OptIn(ExperimentalFoundationApi::class)
class FriendTrackerManagerRoot : Routes.Route() {
@@ -160,8 +154,13 @@ class FriendTrackerManagerRoot : Routes.Route() {
label: String,
icon: ImageVector,
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 backgroundBrush = remember {
Brush.linearGradient(
@@ -185,123 +184,25 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(backgroundBrush, shape)
.padding(horizontal = 14.dp, vertical = 10.dp),
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)
Text(label, color = Color.White, fontWeight = FontWeight.SemiBold)
Icon(icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(20.dp))
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 = {
var showExportDialog by remember { mutableStateOf(false) }
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 }
)
}
// Handled via FloatingTopBar
}
private lateinit var activityLauncherHelper: ActivityLauncherHelper
@@ -347,11 +248,18 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
@Composable
private fun ConfigRulesTab() {
private fun ConfigRulesTab(scrollOffset: (Int) -> Unit) {
val updateRules = rememberAsyncUpdateDispatcher()
val rules = rememberAsyncMutableStateList(defaultValue = listOf(), updateDispatcher = updateRules) {
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
fun EmptyState(text: String) {
Column(
@@ -389,178 +297,175 @@ class FriendTrackerManagerRoot : Routes.Route() {
}
}
Column(
modifier = Modifier.fillMaxSize()
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = routes.bottomPadding)
) {
item {
if (rules.isEmpty()) {
EmptyState(translation["no_rules_found"])
}
item {
if (rules.isEmpty()) {
EmptyState(translation["no_rules_found"])
}
}
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
}
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)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
val ruleShape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable {
routes.editRule.navigate {
this["rule_id"] = rule.id.toString()
}
.padding(8.dp),
shape = ruleShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.5f),
PurrfectPalette.glowSecondary.copy(alpha = 0.4f)
)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
shape = ruleShape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 12.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
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(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, ruleShape)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.18f))
) {
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.18f))
) {
Box(
modifier = Modifier
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
CircleShape
Box(
modifier = Modifier
.size(54.dp)
.background(
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
PurrfectPalette.glowSecondary.copy(alpha = 0.3f)
)
),
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
CircleShape
),
contentAlignment = Alignment.Center
) {
Text(ruleName, fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, color = Color.White)
Text(
buildString {
append(eventCount)
Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = null, tint = Color.White)
}
}
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(translation["events_suffix"])
if (scopeCount > 0) {
append("")
append(scopeCount)
append(" ")
append(translation["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = PurrfectPalette.textSecondary
)
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
}
append(translation["scopes_suffix"])
}
},
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = PurrfectPalette.textSecondary
)
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),
verticalAlignment = Alignment.CenterVertically
) {
scopesBitmoji.take(4).forEach { friend ->
BitmojiImage(
size = 34,
}
Row(
horizontalArrangement = Arrangement.spacedBy((-10).dp),
verticalAlignment = Alignment.CenterVertically
) {
scopesBitmoji.take(4).forEach { friend ->
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
.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),
.padding(horizontal = 10.dp, vertical = 6.dp)
)
}
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,
verticalArrangement = Arrangement.spacedBy(6.dp)
}
Column(
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(
shape = RoundedCornerShape(12.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Text(
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()
Text(
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()
)
}
}
}
@@ -574,10 +479,14 @@ class FriendTrackerManagerRoot : Routes.Route() {
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(initialPage = 0) { titles.size }
currentPage = pagerState.currentPage
var scrollOffset by remember { mutableIntStateOf(0) }
var showExportDialog by remember { mutableStateOf(false) }
var showSingleExportDialog by remember { mutableStateOf(false) }
var showImportDialog 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) {
routes.activityLauncher.openFile("application/json") { uri ->
@@ -606,125 +515,77 @@ class FriendTrackerManagerRoot : Routes.Route() {
.background(PurrfectPalette.backgroundGradient)
) {
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
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
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)
)
)
)
.padding(horizontal = 14.dp, vertical = 12.dp),
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
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.Center
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = context.translation["manager.routes.friend_tracker"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 20.sp
)
Text(
text = titles.getOrNull(pagerState.currentPage) ?: "",
color = PurrfectPalette.textSecondary,
fontSize = 14.sp
)
}
if (pagerState.currentPage == 0) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.End
) {
TrackerPillButton(
label = translation["import_button"],
icon = Icons.Default.FolderOpen,
onClick = { showImportDialog = true }
)
TrackerPillButton(
label = translation["export_button"],
icon = Icons.Default.SaveAlt,
onClick = { showExportDialog = true }
)
}
}
Text(text = text, color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Surface(
HorizontalPager(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 12.dp),
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
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()
}
}
.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,
scrollOffset = { scrollOffset = it }
)
0 -> ConfigRulesTab(scrollOffset = { scrollOffset = it })
}
}
}
@@ -735,8 +596,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showExportDialog = false },
title = translation["export_dialog_title"],
choices = listOf(
translation["bulk_export_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_export_button"]) },
translation["individual_export_button"] to { Icon(Icons.Default.FileOpen, translation["individual_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"], tint = Color.White) }
),
onChoiceSelected = { index ->
showExportDialog = false
@@ -783,8 +644,8 @@ class FriendTrackerManagerRoot : Routes.Route() {
onDismissRequest = { showImportDialog = false },
title = translation["import_dialog_title"],
choices = listOf(
translation["bulk_import_button"] to { Icon(Icons.Default.UploadFile, translation["bulk_import_button"]) },
translation["individual_import_button"] to { Icon(Icons.Default.FileOpen, translation["individual_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"], tint = Color.White) }
),
onChoiceSelected = { index ->
showImportDialog = false
@@ -806,33 +667,51 @@ private fun SelectRuleDialog(
translation: me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
val shape = RoundedCornerShape(24.dp)
Surface(
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 20.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(18.dp),
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(
verticalArrangement = Arrangement.spacedBy(8.dp)
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.heightIn(max = 300.dp)
) {
items(rules) { rule ->
ElevatedCard(
Surface(
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 = rule.name,
modifier = Modifier.padding(16.dp),
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
TextButton(onClick = onDismissRequest) {
Text(translation["button.cancel"])
Text(translation["button.cancel"], color = PurrfectPalette.glowSecondary)
}
}
}
@@ -847,7 +726,7 @@ private fun ChoiceDialog(
onChoiceSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismissRequest) {
val shape = RoundedCornerShape(20.dp)
val shape = RoundedCornerShape(24.dp)
Surface(
shape = shape,
color = Color.Transparent,
@@ -865,9 +744,9 @@ private fun ChoiceDialog(
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, shape)
.padding(horizontal = 18.dp, vertical = 16.dp),
.padding(horizontal = 20.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = title,
@@ -876,36 +755,24 @@ private fun ChoiceDialog(
textAlign = TextAlign.Center
)
choices.forEachIndexed { index, (text, icon) ->
SelectButton(
Surface(
onClick = { onChoiceSelected(index) },
text = text,
leadingIcon = icon
)
modifier = Modifier.fillMaxWidth(),
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
import me.eternal.purrfectsnap.ui.util.Motion
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -7,6 +9,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -61,6 +64,7 @@ fun LogsTab(
deleteAction: (() -> Unit) -> Unit,
exportAction: (() -> Unit) -> Unit,
bottomPadding: Dp,
scrollOffset: (Int) -> Unit
) {
val translation = remember { context.translation.getCategory("manager.friend_tracker") }
val trackerTranslation = remember { context.translation.getCategory("tracker") }
@@ -78,6 +82,12 @@ fun LogsTab(
var filter by remember { mutableStateOf("") }
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(
pageIndex = pageIndex,
pageSize = 30,
@@ -665,13 +675,15 @@ fun LogsTab(
)
}
}
}
}
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = bottomPadding)
) {
}
}
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
contentPadding = PaddingValues(bottom = bottomPadding)
) {
item {
Row(
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.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import okhttp3.OkHttpClient
class ManageFriendTrackerReposSection: Routes.Route() {
@@ -286,10 +287,7 @@ class ManageFriendTrackerReposSection: Routes.Route() {
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
.headerHeightTracker { topBarHeight = it }
)
if (repositories.isEmpty()) {
Box(
@@ -309,9 +307,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 12.dp,
top = topBarHeight + 12.dp,
top = topBarHeight,
end = 12.dp,
bottom = 18.dp + routes.bottomPadding
bottom = routes.bottomPadding
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

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

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -104,7 +105,14 @@ class SaveFolderScreen : SetupScreen() {
color = PurrfectPalette.textSecondary
)
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,
fontWeight = FontWeight.SemiBold,
color = Color.White,

View File

@@ -10,26 +10,31 @@ plugins {
// var versionName = "1.0.0"
// var versionCode = 210
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
abstract class GetVersionTask : DefaultTask() {
@get:Input
abstract val versionName: Property<String>
@get:OutputFile
abstract val versionFile: RegularFileProperty
@TaskAction
fun writeVersion() {
val versionFile = project.layout.projectDirectory.file("app/build/version.txt").asFile
versionFile.parentFile.mkdirs()
versionFile.writeText(versionName.get())
val file = versionFile.get().asFile
file.parentFile.mkdirs()
file.writeText(versionName.get())
}
}
tasks.register<GetVersionTask>("getVersion") {
// Value comes from gradle.properties; falls back to 1.0.0 if not set.
versionName.set(providers.gradleProperty("APP_VERSION_NAME").orElse("1.0.0"))
versionFile.set(project.layout.projectDirectory.file("app/build/version.txt"))
}
// You can still set these for legacy use by submodules or scripts:

View File

@@ -247,7 +247,7 @@
"about_tagline": "Ein Xposed-Modul, um dein Snapchat-Erlebnis zu verbessern!",
"about_lead_developers_title": "Hauptentwickler",
"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_magic_toast": "Tippe 5 Mal auf diesen Bildschirm, um etwas Magie zu sehen 😉!",
"github_button": "GitHub",

View File

@@ -247,7 +247,7 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"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_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"activity": {
"wrong_apk_title": "Wrong APK installed",
@@ -207,6 +207,7 @@
"update_ready_label": "Ready to install",
"purr_aura_active_label": "PurrAura Active!",
"purr_aura_inactive_label": "PurrAura Inactive",
"about_meet_team_button": "About Us",
"open_settings_button": "Open Settings",
"wiki_button": "Wiki",
"github_button": "GitHub",
@@ -246,8 +247,10 @@
"about_title": "PurrfectSnap",
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_dev_external": "ΞTΞRNAL",
"about_dev_rsr": "<RSR/>",
"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_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"github_button": "GitHub",
@@ -3236,7 +3239,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading",
"loading_username": "Loading…",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
@@ -3639,3 +3642,4 @@
"openrouter": "OpenRouter"
}
}

View File

@@ -247,7 +247,7 @@
"about_tagline": "¡Un Módulo Xposed destinado a mejorar tu experiencia en Snapchat!",
"about_lead_developers_title": "Desarrolladores Principales",
"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_magic_toast": "¡Toca 5 veces en esta pantalla para ver algo de magia 😉!",
"github_button": "GitHub",

View File

@@ -247,7 +247,7 @@
"about_tagline": "Un module Xposed conçu pour améliorer votre expérience Snapchat !",
"about_lead_developers_title": "Développeurs principaux",
"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_magic_toast": "Appuyez 5 fois sur cet écran pour voir un peu de magie 😉 !",
"github_button": "GitHub",