From d161d7bde131ea303d880bd13e6cad795598e05c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 28 Mar 2026 23:18:40 +0530 Subject: [PATCH 01/17] fix: oversized toolbar --- .../ui/components/EssentialsFloatingToolbar.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsFloatingToolbar.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsFloatingToolbar.kt index 88f2eb504..5331d4e6b 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsFloatingToolbar.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/EssentialsFloatingToolbar.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -48,6 +50,15 @@ fun EssentialsFloatingToolbar( expanded: Boolean = true ) { val view = LocalView.current + val configuration = LocalConfiguration.current + val fontScale = LocalDensity.current.fontScale + val screenWidth = configuration.screenWidthDp + + // Hide label if font scale is large or screen width is too small + val isLargeFont = fontScale > 1.25f + val isCompactScreen = screenWidth < 400 + + val shouldHideLabel = isLargeFont || (isCompactScreen && items.size > 3) val finalFab: (@Composable () -> Unit)? = when { floatingActionButton != null -> floatingActionButton @@ -151,7 +162,7 @@ fun EssentialsFloatingToolbar( ) val labelWidth by animateDpAsState( - targetValue = if (isSelected) 80.dp else 0.dp, + targetValue = if (isSelected && !shouldHideLabel) 80.dp else 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow @@ -217,7 +228,7 @@ fun EssentialsFloatingToolbar( } } } - if (isSelected) { + if (isSelected && !shouldHideLabel) { Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(id = item.labelRes), From a698d5851229d4e95a9d58b01b32876b8b7a54f7 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 28 Mar 2026 23:43:31 +0530 Subject: [PATCH 02/17] feat: gradient overlay in ProgressiveBlurModifier --- .../essentials/FeatureSettingsActivity.kt | 24 ++++------ .../com/sameerasw/essentials/MainActivity.kt | 24 ++++------ .../sameerasw/essentials/SettingsActivity.kt | 24 ++++------ .../ui/activities/YourAndroidActivity.kt | 24 ++++------ .../ui/modifiers/ProgressiveBlurModifier.kt | 44 +++++++++++++++---- 5 files changed, 67 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt index 284f822cf..85ba098d0 100644 --- a/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/FeatureSettingsActivity.kt @@ -329,28 +329,20 @@ class FeatureSettingsActivity : AppCompatActivity() { modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP ) ) { val hasScroll = featureId != "Sound mode tile" && featureId != "Quick settings tiles" Column( modifier = Modifier .fillMaxSize() - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = with(LocalDensity.current) { 150.dp.toPx() }, - direction = BlurDirection.BOTTOM - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = with(LocalDensity.current) { 150.dp.toPx() }, + direction = BlurDirection.BOTTOM ) .then(if (hasScroll) Modifier.verticalScroll(rememberScrollState()) else Modifier) ) { diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt index d82dc982e..847d58508 100644 --- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt @@ -450,14 +450,10 @@ class MainActivity : AppCompatActivity() { Box( modifier = Modifier .fillMaxSize() - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP ) ) { val currentTab = remember(tabs, currentPage) { @@ -611,14 +607,10 @@ class MainActivity : AppCompatActivity() { modifier = Modifier .scale(1f - (backProgress.value * 0.05f)) .alpha(1f - (backProgress.value * 0.3f)) - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = with(androidx.compose.ui.platform.LocalDensity.current) { 130.dp.toPx() }, - direction = BlurDirection.BOTTOM - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = with(androidx.compose.ui.platform.LocalDensity.current) { 130.dp.toPx() }, + direction = BlurDirection.BOTTOM ), label = "Tab Transition" ) { targetPage -> diff --git a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt index a3ba33a14..8969df34b 100644 --- a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt @@ -147,14 +147,10 @@ class SettingsActivity : AppCompatActivity() { modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP ) ) { val contentPadding = androidx.compose.foundation.layout.PaddingValues( @@ -168,14 +164,10 @@ class SettingsActivity : AppCompatActivity() { viewModel = viewModel, contentPadding = contentPadding, modifier = Modifier - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = with(LocalDensity.current) { 150.dp.toPx() }, - direction = BlurDirection.BOTTOM - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = with(LocalDensity.current) { 150.dp.toPx() }, + direction = BlurDirection.BOTTOM ) ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt index 6f5bff2f4..443f1e1b9 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/YourAndroidActivity.kt @@ -224,14 +224,10 @@ class YourAndroidActivity : ComponentActivity() { modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer) - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = statusBarHeightPx * 1.15f, - direction = BlurDirection.TOP - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP ) ) { androidx.compose.material3.pulltorefresh.PullToRefreshBox( @@ -323,14 +319,10 @@ fun YourAndroidContent( Column( modifier = modifier .fillMaxSize() - .then( - if (isBlurEnabled) { - Modifier.progressiveBlur( - blurRadius = 40f, - height = with(LocalDensity.current) { 150.dp.toPx() }, - direction = BlurDirection.BOTTOM - ) - } else Modifier + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = with(LocalDensity.current) { 150.dp.toPx() }, + direction = BlurDirection.BOTTOM ) .verticalScroll(rememberScrollState()) .padding( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/ProgressiveBlurModifier.kt b/app/src/main/java/com/sameerasw/essentials/ui/modifiers/ProgressiveBlurModifier.kt index a2738e1d2..490fa2f73 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/modifiers/ProgressiveBlurModifier.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/modifiers/ProgressiveBlurModifier.kt @@ -3,8 +3,12 @@ package com.sameerasw.essentials.ui.modifiers import android.graphics.RenderEffect import android.graphics.RuntimeShader import android.os.Build -import androidx.annotation.RequiresApi +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asComposeRenderEffect import androidx.compose.ui.graphics.graphicsLayer import org.intellij.lang.annotations.Language @@ -75,12 +79,13 @@ private val PROGRESSIVE_BLUR_SKSL = """ fun Modifier.progressiveBlur( blurRadius: Float, height: Float, - direction: BlurDirection = BlurDirection.TOP -): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - this.then( + direction: BlurDirection = BlurDirection.TOP, + showGradientOverlay: Boolean = true +): Modifier = composed { + val overlayColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.65f) + + val blurModifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && blurRadius > 0f) { Modifier.graphicsLayer { - if (blurRadius <= 0f) return@graphicsLayer - val shader = RuntimeShader(PROGRESSIVE_BLUR_SKSL) shader.setFloatUniform("blurRadius", blurRadius) shader.setFloatUniform("height", height) @@ -90,7 +95,28 @@ fun Modifier.progressiveBlur( renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content") .asComposeRenderEffect() } - ) -} else { - this + } else Modifier + + val gradientModifier = if (showGradientOverlay) { + Modifier.drawWithContent { + drawContent() + val (brush, _) = when (direction) { + BlurDirection.TOP -> { + Brush.verticalGradient( + colors = listOf(overlayColor, Color.Transparent), + endY = height + ) to height + } + BlurDirection.BOTTOM -> { + Brush.verticalGradient( + colors = listOf(Color.Transparent, overlayColor), + startY = size.height - height + ) to height + } + } + drawRect(brush = brush) + } + } else Modifier + + this.then(blurModifier).then(gradientModifier) } From 13c05e49d795dd69549d36e7cfd737e5e92bc269 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 29 Mar 2026 00:46:15 +0530 Subject: [PATCH 03/17] refactor: migrate custom toggle components to Material3 ListItem --- app/build.gradle.kts | 4 +- .../ui/components/cards/AppToggleItem.kt | 204 ++++++++------ .../ui/components/cards/FeatureCard.kt | 194 ++++++-------- .../ui/components/cards/IconToggleItem.kt | 147 ++++++----- .../ui/components/cards/PermissionCard.kt | 248 +++++++++--------- 5 files changed, 419 insertions(+), 378 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f79651041..73e86271e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,8 +79,8 @@ dependencies { // Android 12+ SplashScreen API with backward compatibility attributes implementation("androidx.core:core-splashscreen:1.0.1") - // Force latest Material3 1.5.0-alpha12 for ToggleButton & ButtonGroup support - implementation("androidx.compose.material3:material3:1.5.0-alpha12") + // Force latest Material3 1.5.0-alpha16 for new ListItem expressive overloads + implementation("androidx.compose.material3:material3:1.5.0-alpha16") implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/AppToggleItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/AppToggleItem.kt index 5182a2b4c..7d7aee2d5 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/AppToggleItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/AppToggleItem.kt @@ -66,6 +66,7 @@ private val GOOGLE_SYSTEM_USER_APPS = setOf( "com.google.android.cellbroadcastreceiver" ) +@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) @Composable fun AppToggleItem( icon: ImageBitmap?, @@ -85,42 +86,67 @@ fun AppToggleItem( isSystemApp || (packageName != null && GOOGLE_SYSTEM_USER_APPS.contains(packageName)) } - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .clickable(enabled = !showToggle && enabled) { + val onClickAction = { + if (enabled) { + HapticUtil.performVirtualKeyHaptic(view) + onCheckedChange(!isChecked) + } else if (onDisabledClick != null) { + HapticUtil.performVirtualKeyHaptic(view) + onDisabledClick() + } + } + + if (showToggle) { + androidx.compose.material3.ListItem( + checked = isChecked && enabled, + onCheckedChange = { checked -> if (enabled) { HapticUtil.performVirtualKeyHaptic(view) - onCheckedChange(!isChecked) + onCheckedChange(checked) } else if (onDisabledClick != null) { HapticUtil.performVirtualKeyHaptic(view) onDisabledClick() } - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - if (icon != null) { - Image( - bitmap = icon, - contentDescription = title, - modifier = Modifier.size(24.dp), - contentScale = ContentScale.Fit - ) - } else { - // Fallback placeholder if needed, or just space - Spacer(modifier = Modifier.size(24.dp)) - } - Spacer(modifier = Modifier.size(2.dp)) - - if (description != null) { - Column(modifier = Modifier.weight(1f)) { + }, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + if (icon != null) { + Image( + bitmap = icon, + contentDescription = title, + modifier = Modifier.size(32.dp), + contentScale = ContentScale.Fit + ) + } else { + Spacer(modifier = Modifier.size(32.dp)) + } + }, + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = null, // Handled by ListItem + enabled = enabled + ) + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + content = { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -149,66 +175,72 @@ fun AppToggleItem( } } } - Text( - text = description, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } - } else { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - if (shouldShowSystemTag) { - Box( - modifier = Modifier - .size(18.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - androidx.compose.material3.Icon( - painter = painterResource(id = R.drawable.round_android_24), - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.surfaceBright - ) - } + ) + } else { + androidx.compose.material3.ListItem( + onClick = onClickAction, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + if (icon != null) { + Image( + bitmap = icon, + contentDescription = title, + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) } - } - } - - if (showToggle) { - Box { - Switch( - checked = if (enabled) isChecked else false, - onCheckedChange = { checked -> - if (enabled) { - HapticUtil.performVirtualKeyHaptic(view) - onCheckedChange(checked) + }, + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (shouldShowSystemTag) { + Box( + modifier = Modifier + .size(18.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Icon( + painter = painterResource(id = R.drawable.round_android_24), + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.surfaceBright + ) } - }, - enabled = enabled - ) - - if (!enabled && onDisabledClick != null) { - Box(modifier = Modifier - .matchParentSize() - .clickable { - HapticUtil.performVirtualKeyHaptic(view) - onDisabledClick() - }) + } } } - } + ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt index dc2537997..40d06b0e4 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt @@ -40,6 +40,7 @@ import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem import com.sameerasw.essentials.utils.ColorUtil import com.sameerasw.essentials.utils.HapticUtil +@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) @Composable fun FeatureCard( title: Any, // Can be Int (Resource ID) or String @@ -90,124 +91,65 @@ fun FeatureCard( label = "alpha" ) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceBright - ), - shape = MaterialTheme.shapes.extraSmall, + val resolvedTitle = when (title) { + is Int -> stringResource(id = title) + is String -> title + else -> "" + } + + androidx.compose.material3.ListItem( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onClick() + }, + onLongClick = { + HapticUtil.performVirtualKeyHaptic(view) + showMenu = true + }, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = modifier .alpha(alpha) - .combinedClickable( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onClick() - }, - onLongClick = { - HapticUtil.performVirtualKeyHaptic(view) - showMenu = true + .blur(blurRadius), + leadingContent = if (iconRes != null) { + { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = ColorUtil.getPastelColorFor(resolvedTitle), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = resolvedTitle, + modifier = Modifier.size(24.dp), + tint = ColorUtil.getVibrantColorFor(resolvedTitle) + ) } - )) { - Box( - modifier = Modifier - .fillMaxWidth() - .blur(blurRadius) - .padding(16.dp) - ) { - - val resolvedTitle = when (title) { - is Int -> stringResource(id = title) - is String -> title - else -> "" } - - Row( - modifier = Modifier.align(Alignment.CenterStart), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (iconRes != null) { - Box( - modifier = Modifier - .size(40.dp) - .background( - color = ColorUtil.getPastelColorFor(resolvedTitle), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = iconRes), - contentDescription = resolvedTitle, - modifier = Modifier.size(24.dp), - tint = ColorUtil.getVibrantColorFor(resolvedTitle) - ) - } - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = resolvedTitle, - color = MaterialTheme.colorScheme.onSurface - ) - if (isBeta) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.background - ), - shape = MaterialTheme.shapes.extraSmall - ) { - Text( - text = stringResource(R.string.label_beta), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary - ) - } - } - } - if (descriptionOverride != null) { - Text( - text = descriptionOverride, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else if (description != null) { - val resolvedDescription = when (description) { - is Int -> stringResource(id = description) - is String -> description - else -> "" - } - Text( - text = resolvedDescription, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + } else null, + supportingContent = if (descriptionOverride != null || description != null) { + { + val desc = descriptionOverride ?: description + val resolvedDescription = when (desc) { + is Int -> stringResource(id = desc) + is String -> desc + else -> "" } + Text( + text = resolvedDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - + } else null, + trailingContent = { Row( - modifier = Modifier.align(Alignment.CenterEnd), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { - if (hasMoreSettings) { - Icon( - modifier = Modifier - .padding(end = 12.dp) - .size(24.dp), - painter = painterResource(id = R.drawable.rounded_chevron_right_24), - contentDescription = "More settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - if (showToggle) { Box { Switch( @@ -222,7 +164,6 @@ fun FeatureCard( ) if (!isToggleEnabled && onDisabledToggleClick != null) { - // Invisible overlay catches taps even if the child consumes them Box(modifier = Modifier .matchParentSize() .clickable { @@ -233,6 +174,39 @@ fun FeatureCard( } } } + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = resolvedTitle, + color = MaterialTheme.colorScheme.onSurface + ) + if (isBeta) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background + ), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = stringResource(R.string.label_beta), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } SegmentedDropdownMenu( expanded = showMenu, @@ -279,5 +253,5 @@ fun FeatureCard( } } } - } + ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt index 2e80a78b5..b9f5b6820 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.sameerasw.essentials.utils.HapticUtil +@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) @Composable fun IconToggleItem( iconRes: Int, @@ -37,80 +38,108 @@ fun IconToggleItem( ) { val view = LocalView.current - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .clickable(enabled = !showToggle && enabled) { + val onClickAction = { + if (enabled) { + HapticUtil.performVirtualKeyHaptic(view) + onCheckedChange(!isChecked) + } else if (onDisabledClick != null) { + HapticUtil.performVirtualKeyHaptic(view) + onDisabledClick() + } + } + + if (showToggle) { + androidx.compose.material3.ListItem( + checked = isChecked && enabled, + onCheckedChange = { checked -> if (enabled) { HapticUtil.performVirtualKeyHaptic(view) - onCheckedChange(!isChecked) + onCheckedChange(checked) } else if (onDisabledClick != null) { HapticUtil.performVirtualKeyHaptic(view) onDisabledClick() } - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(id = iconRes), - contentDescription = title, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.size(2.dp)) - - if (description != null) { - Column(modifier = Modifier.weight(1f)) { + }, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = null, // Handled by ListItem + enabled = enabled + ) + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { Text( text = title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface ) - Text( - text = description, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } - } else { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } - - if (showToggle) { - Box { - Switch( - checked = if (enabled) isChecked else false, - onCheckedChange = { checked -> - if (enabled) { - HapticUtil.performVirtualKeyHaptic(view) - onCheckedChange(checked) - } - }, - enabled = enabled + ) + } else { + androidx.compose.material3.ListItem( + onClick = onClickAction, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary ) - - if (!enabled && onDisabledClick != null) { - Box(modifier = Modifier - .matchParentSize() - .clickable { - HapticUtil.performVirtualKeyHaptic(view) - onDisabledClick() - }) + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + } else null, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) } - } + ) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/PermissionCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/PermissionCard.kt index 0f7431413..9892a0776 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/PermissionCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/PermissionCard.kt @@ -1,6 +1,7 @@ package com.sameerasw.essentials.ui.components.cards 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 @@ -10,8 +11,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -26,7 +29,7 @@ import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.utils.HapticUtil -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) @Composable fun PermissionCard( iconRes: Int, @@ -42,141 +45,144 @@ fun PermissionCard( val grantedGreen = Color(0xFF4CAF50) val view = LocalView.current - Card(modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - - Spacer(modifier = Modifier.size(12.dp)) - - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - tint = if (isGranted) grantedGreen else MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) + val resolvedTitle = when (title) { + is Int -> stringResource(id = title) + is String -> title + else -> "" + } - Spacer(modifier = Modifier.size(24.dp)) + val resolvedActionLabel = when (actionLabel) { + is Int -> stringResource(id = actionLabel) + is String -> actionLabel + else -> "" + } - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val resolvedTitle = when (title) { - is Int -> stringResource(id = title) - is String -> title - else -> "" - } - Text(text = resolvedTitle, style = MaterialTheme.typography.titleMedium) - } + val resolvedSecondaryLabel = when (secondaryActionLabel) { + is Int -> stringResource(id = secondaryActionLabel as Int) + is String -> secondaryActionLabel + else -> null + } - Spacer(modifier = Modifier.height(4.dp)) - Text(text = "Required for:", style = MaterialTheme.typography.bodySmall) - Spacer(modifier = Modifier.height(4.dp)) - // Bulleted list of dependent features - dependentFeatures.forEach { f -> - val resolvedFeature = when (f) { - is Int -> stringResource(id = f) - is String -> f - else -> "" + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) + ) { + Column( + modifier = Modifier.padding(bottom = 12.dp, start = 4.dp, end = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = if (isGranted) grantedGreen else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp) + ) + }, + supportingContent = { + Column { + Text(text = "Required for:", style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.height(4.dp)) + dependentFeatures.forEach { f -> + val resolvedFeature = when (f) { + is Int -> stringResource(id = f) + is String -> f + else -> "" + } + Text( + text = "• $resolvedFeature", + style = MaterialTheme.typography.bodyMedium + ) } - Text( - text = "• $resolvedFeature", - style = MaterialTheme.typography.bodyMedium - ) } + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = Color.Transparent + ), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + content = { + Text(text = resolvedTitle, style = MaterialTheme.typography.titleMedium) } - } - - val resolvedActionLabel = when (actionLabel) { - is Int -> stringResource(id = actionLabel) - is String -> actionLabel - else -> "" - } + ) - val resolvedSecondaryLabel = when (secondaryActionLabel) { - is Int -> stringResource(id = secondaryActionLabel as Int) - is String -> secondaryActionLabel - else -> null - } - - if (isGranted) { - if (resolvedSecondaryLabel != null && onSecondaryActionClick != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onActionClick() - }, - modifier = Modifier.weight(1f) + Box(modifier = Modifier.padding(horizontal = 12.dp)) { + if (isGranted) { + if (resolvedSecondaryLabel != null && onSecondaryActionClick != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(resolvedActionLabel) - } + OutlinedButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onActionClick() + }, + modifier = Modifier.weight(1f) + ) { + Text(resolvedActionLabel) + } - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onSecondaryActionClick() - }, - modifier = Modifier.weight(1f) - ) { - Text(resolvedSecondaryLabel) + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSecondaryActionClick() + }, + modifier = Modifier.weight(1f) + ) { + Text(resolvedSecondaryLabel) + } + } + } else { + OutlinedButton(onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onActionClick() + }, modifier = Modifier.fillMaxWidth()) { + Text(resolvedActionLabel) } } } else { - OutlinedButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onActionClick() - }, modifier = Modifier.fillMaxWidth()) { - Text(resolvedActionLabel) - Spacer(modifier = Modifier.weight(1f)) - Icon( - painter = painterResource(id = R.drawable.rounded_arrow_forward_24), - contentDescription = null - ) - } - } - } else { - // Show buttons - either single or dual buttons - if (resolvedSecondaryLabel != null && onSecondaryActionClick != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedButton( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onActionClick() - }, - modifier = Modifier.weight(1f) + if (resolvedSecondaryLabel != null && onSecondaryActionClick != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text(resolvedActionLabel) - } + OutlinedButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onActionClick() + }, + modifier = Modifier.weight(1f) + ) { + Text(resolvedActionLabel) + } - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onSecondaryActionClick() - }, - modifier = Modifier.weight(1f) - ) { - Text(resolvedSecondaryLabel) + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSecondaryActionClick() + }, + modifier = Modifier.weight(1f) + ) { + Text(resolvedSecondaryLabel) + } + } + } else { + Button(onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onActionClick() + }, modifier = Modifier.fillMaxWidth()) { + Text(resolvedActionLabel) } - } - } else { - Button(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onActionClick() - }, modifier = Modifier.fillMaxWidth()) { - Text(resolvedActionLabel) - Spacer(modifier = Modifier.weight(1f)) - Icon( - painter = painterResource(id = R.drawable.rounded_arrow_forward_24), - contentDescription = null - ) } } } From 8e721a4d983ffc106c1d8bbf210d3fb9204aaa09 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 29 Mar 2026 01:16:05 +0530 Subject: [PATCH 04/17] refactor: migrate picker components to use ListItem --- .../sameerasw/essentials/SettingsActivity.kt | 23 +-- .../ui/components/cards/IconToggleItem.kt | 4 +- .../pickers/CrashReportingPicker.kt | 57 +++--- .../ui/components/pickers/DefaultTabPicker.kt | 116 ++++++++----- .../ui/components/pickers/LanguagePicker.kt | 108 ++++++------ .../ui/components/sheets/UpdateBottomSheet.kt | 54 +++--- .../configs/BatteriesSettingsUI.kt | 163 ++++++++++-------- app/src/main/res/values/strings.xml | 1 + 8 files changed, 297 insertions(+), 229 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt index 8969df34b..f015a3b0d 100644 --- a/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt @@ -382,13 +382,6 @@ fun SettingsContent( } - Text( - text = "Default tab", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - val defaultTab by viewModel.defaultTab RoundedCardContainer { val availableTabs = remember { DIYTabs.entries } @@ -760,8 +753,8 @@ fun SettingsContent( .background( color = MaterialTheme.colorScheme.surfaceBright ) - .padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Button( onClick = { @@ -791,19 +784,21 @@ fun SettingsContent( .background( color = MaterialTheme.colorScheme.surfaceBright ) - .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 12.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( onClick = { HapticUtil.performVirtualKeyHaptic(view) viewModel.resetOnboarding(context) - // Navigate back to main screen - (context as? ComponentActivity)?.finish() + Toast.makeText(context, "Onboarding reset", Toast.LENGTH_SHORT).show() }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) ) { - Text("Reset onboarding") + Text("Reset App Data", color = MaterialTheme.colorScheme.onError) } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt index b9f5b6820..d62710eed 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt @@ -67,7 +67,7 @@ fun IconToggleItem( Icon( painter = painterResource(id = iconRes), contentDescription = title, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) }, @@ -112,7 +112,7 @@ fun IconToggleItem( Icon( painter = painterResource(id = iconRes), contentDescription = title, - modifier = Modifier.size(32.dp), + modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) }, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/CrashReportingPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/CrashReportingPicker.kt index 5c581909c..316052210 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/CrashReportingPicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/CrashReportingPicker.kt @@ -33,37 +33,42 @@ fun CrashReportingPicker( Column( modifier = modifier .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + .background(MaterialTheme.colorScheme.surfaceBright), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - Row( + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.size(2.dp)) - - Text( - text = stringResource(R.string.sentry_report_mode_title), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { + Text( + text = stringResource(R.string.sentry_report_mode_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + ) Row( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), ) { options.forEachIndexed { index, option -> diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/DefaultTabPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/DefaultTabPicker.kt index 5940557a8..84511cb67 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/DefaultTabPicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/DefaultTabPicker.kt @@ -2,13 +2,18 @@ package com.sameerasw.essentials.ui.components.pickers import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton @@ -34,49 +39,82 @@ fun DefaultTabPicker( options: List = DIYTabs.entries ) { - Row( + Column( modifier = modifier - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .padding(10.dp), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - options.forEachIndexed { index, tab -> - val isChecked = selectedTab == tab + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + leadingContent = { + Icon( + painter = painterResource(id = R.drawable.rounded_widgets_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { + Text( + text = stringResource(R.string.label_default_tab), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + options.forEachIndexed { index, tab -> + val isChecked = selectedTab == tab - ToggleButton( - checked = isChecked, - onCheckedChange = { - onTabSelected(tab) - }, - modifier = Modifier - .weight(1f) - .semantics { role = Role.RadioButton }, - shapes = when { - index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() - index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() - else -> ButtonGroupDefaults.connectedMiddleButtonShapes() - }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + ToggleButton( + checked = isChecked, + onCheckedChange = { + onTabSelected(tab) + }, + modifier = Modifier + .weight(1f) + .semantics { role = Role.RadioButton }, + shapes = when { + index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, ) { - Icon( - painter = painterResource(id = tab.iconRes), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Text( - text = if (tab == DIYTabs.FREEZE) stringResource(R.string.tab_freeze_title) else stringResource( - tab.title - ), - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isChecked) FontWeight.Bold else FontWeight.Normal, - maxLines = 1 - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource(id = tab.iconRes), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Text( + text = if (tab == DIYTabs.FREEZE) stringResource(R.string.tab_freeze_title) else stringResource( + tab.title + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isChecked) FontWeight.Bold else FontWeight.Normal, + maxLines = 1 + ) + } } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/LanguagePicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/LanguagePicker.kt index baf4b61a4..21d07c27d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/LanguagePicker.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/pickers/LanguagePicker.kt @@ -27,69 +27,69 @@ fun LanguagePicker( val languages = LanguageUtils.languages val selectedLanguage = languages.find { it.code == selectedLanguageCode } ?: languages.first() - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.size(0.dp)) - + ListItem( + onClick = {}, + modifier = modifier.fillMaxWidth(), + leadingContent = { Icon( painter = painterResource(id = R.drawable.rounded_globe_24), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) - - Column { - Text( - text = stringResource(R.string.label_app_language), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - // modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = "${selectedLanguage.nativeName} (${selectedLanguage.name})", - onValueChange = {}, - readOnly = true, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryEditable, true), - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), - shape = RoundedCornerShape(12.dp) - ) - - ExposedDropdownMenu( + }, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + trailingContent = { + ExposedDropdownMenuBox( expanded = expanded, - onDismissRequest = { expanded = false } + onExpandedChange = { expanded = !expanded }, ) { - languages.forEach { language -> - DropdownMenuItem( - text = { - Text(text = "${language.nativeName} (${language.name})") - }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onLanguageSelected(language.code) - expanded = false - }, - contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding - ) + OutlinedTextField( + value = "${selectedLanguage.nativeName} (${selectedLanguage.name})", + onValueChange = {}, + readOnly = true, + modifier = Modifier + .widthIn(max = 200.dp) // Limit width to prevent overflow + .menuAnchor(MenuAnchorType.PrimaryEditable, true), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + shape = RoundedCornerShape(12.dp), + textStyle = MaterialTheme.typography.bodySmall + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + languages.forEach { language -> + DropdownMenuItem( + text = { + Text(text = "${language.nativeName} (${language.name})") + }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onLanguageSelected(language.code) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } } } + }, + content = { + Text( + text = stringResource(R.string.label_app_language), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) } - } + ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/UpdateBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/UpdateBottomSheet.kt index d7ab8a7c1..a89dcea7d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/UpdateBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/UpdateBottomSheet.kt @@ -16,8 +16,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton @@ -33,6 +33,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator import androidx.core.net.toUri import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.UpdateInfo @@ -101,26 +104,33 @@ fun UpdateBottomSheet( if (isPreRelease) { RoundedCardContainer { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_mobile_code_24), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp) - ) - Text( - text = stringResource(R.string.warning_pre_release), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + leadingContent = { + Icon( + painter = painterResource(id = R.drawable.rounded_mobile_code_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + }, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + ), + content = { + Text( + text = stringResource(R.string.warning_pre_release), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + ) } Spacer(modifier = Modifier.height(8.dp)) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/BatteriesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/BatteriesSettingsUI.kt index 141df8b6a..ddc6f7671 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/BatteriesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/BatteriesSettingsUI.kt @@ -14,12 +14,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -69,49 +72,58 @@ fun BatteriesSettingsUI( } ) } else { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceBright) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(R.drawable.rounded_laptop_mac_24), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.size(2.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.download_airsync), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + leadingContent = { + Icon( + painter = painterResource(R.drawable.rounded_laptop_mac_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary ) + }, + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + verticalAlignment = Alignment.CenterVertically, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + trailingContent = { + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=com.sameerasw.airsync") + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + }, + colors = ButtonDefaults.filledTonalButtonColors() + ) { + Text(stringResource(R.string.action_download)) + } + }, + content = { + Column { + Text( + text = stringResource(R.string.download_airsync), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + supportingContent = { Text( text = stringResource(R.string.download_airsync_summary), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - val intent = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=com.sameerasw.airsync") - ) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - }, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Text(stringResource(R.string.action_download)) - } - } + ) } // Bluetooth Devices @@ -176,43 +188,50 @@ fun BatteriesSettingsUI( ) RoundedCardContainer { - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceBright) - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Text( - text = stringResource(R.string.limit_max_devices_summary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Slider( - value = viewModel.batteryWidgetMaxDevices.intValue.toFloat(), - onValueChange = { - val newInt = it.toInt() - if (newInt != viewModel.batteryWidgetMaxDevices.intValue) { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.setBatteryWidgetMaxDevices(newInt, context) - } - }, - valueRange = 1f..8f, - steps = 6, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(12.dp)) + ListItem( + onClick = {}, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { Text( - text = viewModel.batteryWidgetMaxDevices.intValue.toString(), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary + text = stringResource(R.string.limit_max_devices_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Slider( + value = viewModel.batteryWidgetMaxDevices.intValue.toFloat(), + onValueChange = { + val newInt = it.toInt() + if (newInt != viewModel.batteryWidgetMaxDevices.intValue) { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setBatteryWidgetMaxDevices(newInt, context) + } + }, + valueRange = 1f..8f, + steps = 6, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = viewModel.batteryWidgetMaxDevices.intValue.toString(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } } - } + ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 285755d2f..287db438e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Flashlight Pulse Check for pre-releases Might be unstable + Default tab Security From cca049b7ebbfe0e70f55d58761a5d1ae679bb9bd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 29 Mar 2026 01:16:30 +0530 Subject: [PATCH 05/17] feat: card separator for dual action cards --- .../ui/components/cards/FeatureCard.kt | 14 ++ .../ui/components/cards/IconToggleItem.kt | 175 +++++++++++++----- 2 files changed, 139 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt index 40d06b0e4..38ed098fe 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/FeatureCard.kt @@ -20,6 +20,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -150,6 +154,16 @@ fun FeatureCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { + if (showToggle && hasMoreSettings) { + VerticalDivider( + modifier = Modifier + .height(32.dp) + .width(1.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + } + if (showToggle) { Box { Switch( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt index d62710eed..88a23b444 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/IconToggleItem.kt @@ -20,7 +20,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.material3.VerticalDivider import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height import com.sameerasw.essentials.utils.HapticUtil @OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) @@ -34,7 +37,8 @@ fun IconToggleItem( onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true, onDisabledClick: (() -> Unit)? = null, - showToggle: Boolean = true + showToggle: Boolean = true, + onClick: (() -> Unit)? = null ) { val view = LocalView.current @@ -49,59 +53,130 @@ fun IconToggleItem( } if (showToggle) { - androidx.compose.material3.ListItem( - checked = isChecked && enabled, - onCheckedChange = { checked -> - if (enabled) { - HapticUtil.performVirtualKeyHaptic(view) - onCheckedChange(checked) - } else if (onDisabledClick != null) { - HapticUtil.performVirtualKeyHaptic(view) - onDisabledClick() + if (onClick != null) { + androidx.compose.material3.ListItem( + onClick = { + if (enabled) { + HapticUtil.performVirtualKeyHaptic(view) + onClick() + } else if (onDisabledClick != null) { + HapticUtil.performVirtualKeyHaptic(view) + onDisabledClick() + } + }, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + VerticalDivider( + modifier = Modifier + .height(32.dp) + .width(1.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = { checked -> + if (enabled) { + HapticUtil.performVirtualKeyHaptic(view) + onCheckedChange(checked) + } + }, + enabled = enabled + ) + } + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) } - }, - enabled = enabled, - modifier = modifier.fillMaxWidth(), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - leadingContent = { - Icon( - painter = painterResource(id = iconRes), - contentDescription = title, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - }, - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 16.dp - ), - supportingContent = if (description != null) { - { + ) + } else { + androidx.compose.material3.ListItem( + checked = isChecked && enabled, + onCheckedChange = { checked -> + if (enabled) { + HapticUtil.performVirtualKeyHaptic(view) + onCheckedChange(checked) + } else if (onDisabledClick != null) { + HapticUtil.performVirtualKeyHaptic(view) + onDisabledClick() + } + }, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + leadingContent = { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + }, + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 16.dp + ), + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = null, // Handled by ListItem + enabled = enabled + ) + }, + colors = androidx.compose.material3.ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ), + content = { Text( - text = description, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) } - } else null, - trailingContent = { - Switch( - checked = if (enabled) isChecked else false, - onCheckedChange = null, // Handled by ListItem - enabled = enabled - ) - }, - colors = androidx.compose.material3.ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceBright - ), - content = { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - ) + ) + } } else { androidx.compose.material3.ListItem( onClick = onClickAction, From 4dcf419ef3d091a6b97764589601657fc4bc4ed0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 18:41:59 +0530 Subject: [PATCH 06/17] feat: add search functionality to Freeze --- .../ui/activities/AppFreezingActivity.kt | 2 +- .../essentials/ui/composables/FreezeGridUI.kt | 390 ++++++++++++------ app/src/main/res/values/strings.xml | 1 + 3 files changed, 261 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt index 3c62f6d56..180ac8985 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt @@ -283,7 +283,7 @@ fun AppGridItem( Surface( shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, + color = MaterialTheme.colorScheme.surfaceBright, modifier = Modifier .fillMaxWidth() .combinedClickable( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt index 01dd03373..e5896fdea 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +32,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,14 +43,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -56,7 +65,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer @@ -83,6 +99,26 @@ fun FreezeGridUI( val frozenStates = remember { mutableStateMapOf() } val lifecycleOwner = LocalLifecycleOwner.current + var searchQuery by rememberSaveable { mutableStateOf("") } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var isFocused by remember { mutableStateOf(false) } + + val filteredApps = remember(pickedApps, searchQuery) { + if (searchQuery.isBlank()) { + pickedApps + } else { + pickedApps.filter { app -> + app.appName.contains(searchQuery, ignoreCase = true) || + app.packageName.contains(searchQuery, ignoreCase = true) + } + } + } + + val bestMatch = remember(searchQuery, filteredApps) { + if (searchQuery.isNotBlank() && filteredApps.isNotEmpty()) filteredApps.first() else null + } + // Refresh frozen states when active DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> @@ -185,159 +221,243 @@ fun FreezeGridUI( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + } ) { Spacer(modifier = Modifier.height(contentPadding.calculateTopPadding())) - Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_search_24), + contentDescription = stringResource(R.string.label_search_content_description), + modifier = Modifier.size(24.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + HapticUtil.performVirtualKeyHaptic(view) + }) { + Icon( + painter = painterResource(id = R.drawable.rounded_close_24), + contentDescription = stringResource(R.string.action_stop) + ) + } + } + }, + placeholder = { + Text(stringResource(R.string.search_frozen_apps_placeholder)) + }, + shape = MaterialTheme.shapes.extraExtraLarge, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + capitalization = KeyboardCapitalization.Words + ), + keyboardActions = KeyboardActions( + onSearch = { + bestMatch?.let { app -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.launchAndUnfreezeApp(context, app.packageName) + } + } + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) RoundedCardContainer( modifier = Modifier .padding(horizontal = 16.dp), ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = MaterialTheme.shapes.extraSmall + ) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = MaterialTheme.shapes.extraSmall - ) - .padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), - verticalAlignment = Alignment.CenterVertically + // Freeze Button + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.freezeAllAuto(context) + }, + modifier = Modifier.weight(1f), + enabled = isShizukuAvailable && isShizukuPermissionGranted, + shape = ButtonDefaults.shape ) { - // Freeze Button - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.freezeAllAuto(context) - }, - modifier = Modifier.weight(1f), - enabled = isShizukuAvailable && isShizukuPermissionGranted, - shape = ButtonDefaults.shape - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_mode_cool_24), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.action_freeze)) - } + Icon( + painter = painterResource(id = R.drawable.rounded_mode_cool_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.action_freeze)) + } - // Unfreeze Button - Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.unfreezeAllAuto(context) - }, - modifier = Modifier.weight(1f), - enabled = isShizukuAvailable && isShizukuPermissionGranted, - shape = ButtonDefaults.shape - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_mode_cool_off_24), - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.size(8.dp)) - Text(stringResource(R.string.action_unfreeze)) - } + Spacer(Modifier.size(ButtonGroupDefaults.ConnectedSpaceBetween)) - // More Menu Button - IconButton( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - isMenuExpanded = true - }, - enabled = isShizukuAvailable && isShizukuPermissionGranted + // Unfreeze Button + Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.unfreezeAllAuto(context) + }, + modifier = Modifier.weight(1f), + enabled = isShizukuAvailable && isShizukuPermissionGranted, + shape = ButtonDefaults.shape + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_mode_cool_off_24), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.action_unfreeze)) + } + + // More Menu Button + IconButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + isMenuExpanded = true + }, + enabled = isShizukuAvailable && isShizukuPermissionGranted + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_more_vert_24), + contentDescription = stringResource(R.string.content_desc_more_options) + ) + + DropdownMenu( + expanded = isMenuExpanded, + onDismissRequest = { isMenuExpanded = false } ) { - Icon( - painter = painterResource(id = R.drawable.rounded_more_vert_24), - contentDescription = stringResource(R.string.content_desc_more_options) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_freeze_all)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.freezeAllManual(context) + isMenuExpanded = false + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_mode_cool_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_unfreeze_all)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + viewModel.unfreezeAllManual(context) + isMenuExpanded = false + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_mode_cool_off_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_export_freeze)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + exportLauncher.launch("freeze_apps_backup.json") + isMenuExpanded = false + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_arrow_warm_up_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_import_freeze)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + importLauncher.launch(arrayOf("application/json")) + isMenuExpanded = false + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_arrow_cool_down_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } ) - - DropdownMenu( - expanded = isMenuExpanded, - onDismissRequest = { isMenuExpanded = false } - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.action_freeze_all)) }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.freezeAllManual(context) - isMenuExpanded = false - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_mode_cool_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_unfreeze_all)) }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.unfreezeAllManual(context) - isMenuExpanded = false - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_mode_cool_off_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_export_freeze)) }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - exportLauncher.launch("freeze_apps_backup.json") - isMenuExpanded = false - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_arrow_warm_up_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.action_import_freeze)) }, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - importLauncher.launch(arrayOf("application/json")) - isMenuExpanded = false - }, - leadingIcon = { - Icon( - painter = painterResource(id = R.drawable.rounded_arrow_cool_down_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - } - ) - } } } + } + } - // App Grid Items - val chunkedApps = pickedApps.chunked(4) + Spacer(modifier = Modifier.height(16.dp)) + + // App Grid Items + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (filteredApps.isEmpty() && searchQuery.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "¯\\_(ツ)_/¯", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(id = R.string.search_no_results, searchQuery), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } else { + val chunkedApps = filteredApps.chunked(4) Column( modifier = Modifier .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { chunkedApps.forEach { rowApps -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { rowApps.forEach { app -> Box(modifier = Modifier.weight(1f)) { @@ -345,6 +465,7 @@ fun FreezeGridUI( app = app, isFrozen = frozenStates[app.packageName] ?: false, isAutoFreezeEnabled = app.isEnabled, + isHighlighted = (app == bestMatch && searchQuery.isNotEmpty()), onClick = { HapticUtil.performVirtualKeyHaptic(view) viewModel.launchAndUnfreezeApp( @@ -379,15 +500,22 @@ fun AppGridItem( app: NotificationApp, isFrozen: Boolean, isAutoFreezeEnabled: Boolean, + isHighlighted: Boolean = false, onClick: () -> Unit, onLongClick: () -> Unit ) { val view = LocalView.current val grayscaleMatrix = remember { ColorMatrix().apply { setToSaturation(0.4f) } } + + val borderColor by animateColorAsState( + targetValue = if (isHighlighted) MaterialTheme.colorScheme.primary else Color.Transparent, + label = "borderColorAnimation" + ) Surface( - shape = RoundedCornerShape(4.dp), + shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceBright, + border = if (isHighlighted) BorderStroke(2.dp, borderColor) else null, modifier = Modifier .fillMaxWidth() .combinedClickable( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 287db438e..2543f3f39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -606,6 +606,7 @@ Search Stop Search + Search frozen apps Back From 17bdd971d23cbf088c6095bed37c711581ed3adc Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 18:56:58 +0530 Subject: [PATCH 07/17] feat: implement segmented dropdown menu for frozen apps --- .../essentials/ui/composables/FreezeGridUI.kt | 151 +++++++++++++++++- .../res/drawable/rounded_home_health_24.xml | 5 + .../res/drawable/rounded_snowflake_24.xml | 5 + app/src/main/res/values/strings.xml | 3 + 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_home_health_24.xml create mode 100644 app/src/main/res/drawable/rounded_snowflake_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt index e5896fdea..c4b58d321 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt @@ -73,6 +73,18 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem +import com.sameerasw.essentials.ui.state.LocalMenuStateManager import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.NotificationApp import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer @@ -81,6 +93,8 @@ import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.ShortcutUtil import com.sameerasw.essentials.viewmodels.MainViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -93,6 +107,8 @@ fun FreezeGridUI( ) { val context = LocalContext.current val view = LocalView.current + val menuState = LocalMenuStateManager.current + val scope = rememberCoroutineScope() val pickedApps by viewModel.freezePickedApps val isPickedAppsLoading by viewModel.isFreezePickedAppsLoading @@ -466,6 +482,7 @@ fun FreezeGridUI( isFrozen = frozenStates[app.packageName] ?: false, isAutoFreezeEnabled = app.isEnabled, isHighlighted = (app == bestMatch && searchQuery.isNotEmpty()), + menuState = menuState, onClick = { HapticUtil.performVirtualKeyHaptic(view) viewModel.launchAndUnfreezeApp( @@ -473,8 +490,21 @@ fun FreezeGridUI( app.packageName ) }, - onLongClick = { - ShortcutUtil.pinAppShortcut(context, app) + onToggleFreeze = { + scope.launch(Dispatchers.IO) { + val isCurrentlyFrozen = frozenStates[app.packageName] ?: false + if (isCurrentlyFrozen) { + FreezeManager.unfreezeApp(context, app.packageName) + } else { + FreezeManager.freezeApp(context, app.packageName) + } + withContext(Dispatchers.Main) { + frozenStates[app.packageName] = !isCurrentlyFrozen + } + } + }, + onRemove = { + viewModel.updateFreezeAppEnabled(context, app.packageName, false) } ) } @@ -501,10 +531,42 @@ fun AppGridItem( isFrozen: Boolean, isAutoFreezeEnabled: Boolean, isHighlighted: Boolean = false, + menuState: com.sameerasw.essentials.ui.state.MenuStateManager, onClick: () -> Unit, - onLongClick: () -> Unit + onToggleFreeze: () -> Unit, + onRemove: () -> Unit ) { val view = LocalView.current + val context = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + + val isBlurred = menuState.activeId != null && menuState.activeId != app.packageName + val blurRadius by animateDpAsState( + targetValue = if (isBlurred) 10.dp else 0.dp, + animationSpec = tween(durationMillis = 500), + label = "blur" + ) + val alpha by animateFloatAsState( + targetValue = if (isBlurred) 0.5f else 1f, + animationSpec = tween(durationMillis = 500), + label = "alpha" + ) + + DisposableEffect(showMenu) { + if (showMenu) { + menuState.activeId = app.packageName + } else { + if (menuState.activeId == app.packageName) { + menuState.activeId = null + } + } + onDispose { + if (menuState.activeId == app.packageName) { + menuState.activeId = null + } + } + } + val grayscaleMatrix = remember { ColorMatrix().apply { setToSaturation(0.4f) } } val borderColor by animateColorAsState( @@ -518,6 +580,8 @@ fun AppGridItem( border = if (isHighlighted) BorderStroke(2.dp, borderColor) else null, modifier = Modifier .fillMaxWidth() + .alpha(alpha) + .blur(blurRadius) .combinedClickable( onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -525,7 +589,7 @@ fun AppGridItem( }, onLongClick = { HapticUtil.performVirtualKeyHaptic(view) - onLongClick() + showMenu = true } ) ) { @@ -575,16 +639,89 @@ fun AppGridItem( } } - Spacer(modifier = Modifier.height(10.dp)) - Text( text = app.appName, style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (isFrozen) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.onSurface ) + + SegmentedDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + SegmentedDropdownMenuItem( + text = { + Text(if (isFrozen) stringResource(R.string.action_unfreeze) else stringResource(R.string.action_freeze)) + }, + onClick = { + showMenu = false + onToggleFreeze() + }, + leadingIcon = { + Icon( + painter = painterResource(id = if (isFrozen) R.drawable.rounded_mode_cool_off_24 else R.drawable.rounded_mode_cool_24), + contentDescription = null + ) + } + ) + + SegmentedDropdownMenuItem( + text = { + Text(stringResource(R.string.action_remove)) + }, + onClick = { + showMenu = false + onRemove() + }, + enabled = !isFrozen, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_delete_24), + contentDescription = null + ) + } + ) + + SegmentedDropdownMenuItem( + text = { + Text(stringResource(R.string.action_create_shortcut)) + }, + onClick = { + showMenu = false + ShortcutUtil.pinAppShortcut(context, app) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_home_health_24), + contentDescription = null + ) + } + ) + + SegmentedDropdownMenuItem( + text = { + Text(stringResource(R.string.action_app_info)) + }, + onClick = { + showMenu = false + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", app.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_info_24), + contentDescription = null + ) + } + ) + } } } } diff --git a/app/src/main/res/drawable/rounded_home_health_24.xml b/app/src/main/res/drawable/rounded_home_health_24.xml new file mode 100644 index 000000000..43c5c05a1 --- /dev/null +++ b/app/src/main/res/drawable/rounded_home_health_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_snowflake_24.xml b/app/src/main/res/drawable/rounded_snowflake_24.xml new file mode 100644 index 000000000..765e8054a --- /dev/null +++ b/app/src/main/res/drawable/rounded_snowflake_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2543f3f39..911dbed1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,9 @@ App Control Freeze Unfreeze + Remove + Create shortcut + App info More options Freeze all apps Unfreeze all apps From 9273d3e46c1872644c1bebcf406c799f017b64da Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 19:02:59 +0530 Subject: [PATCH 08/17] refactor: replace standard DropdownMenu components with SegmentedDropdownMenu in Freeze UI components --- .../essentials/ui/composables/FreezeGridUI.kt | 10 +++++----- .../ui/composables/configs/FreezeSettingsUI.kt | 12 +++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt index c4b58d321..46d392974 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt @@ -365,11 +365,11 @@ fun FreezeGridUI( contentDescription = stringResource(R.string.content_desc_more_options) ) - DropdownMenu( + SegmentedDropdownMenu( expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false } ) { - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_freeze_all)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -384,7 +384,7 @@ fun FreezeGridUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_unfreeze_all)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -399,7 +399,7 @@ fun FreezeGridUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_export_freeze)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -414,7 +414,7 @@ fun FreezeGridUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_import_freeze)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt index 825affa4c..7e7c27a50 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.FreezeMode +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu +import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem import com.sameerasw.essentials.ui.components.cards.AppToggleItem import com.sameerasw.essentials.ui.components.cards.FeatureCard import com.sameerasw.essentials.ui.components.cards.IconToggleItem @@ -198,11 +200,11 @@ fun FreezeSettingsUI( contentDescription = stringResource(R.string.content_desc_more_options) ) - DropdownMenu( + SegmentedDropdownMenu( expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false } ) { - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_freeze_all)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -217,7 +219,7 @@ fun FreezeSettingsUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_unfreeze_all)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -232,7 +234,7 @@ fun FreezeSettingsUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_export_freeze)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) @@ -247,7 +249,7 @@ fun FreezeSettingsUI( ) } ) - DropdownMenuItem( + SegmentedDropdownMenuItem( text = { Text(stringResource(R.string.action_import_freeze)) }, onClick = { HapticUtil.performVirtualKeyHaptic(view) From b41c94cdf7733e09d208b6f6978977ec52a220e0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 19:32:13 +0530 Subject: [PATCH 09/17] feat: Freeze and suspension details --- .../composables/configs/FreezeSettingsUI.kt | 98 +++++++++++++++++++ app/src/main/res/values/strings.xml | 7 ++ 2 files changed, 105 insertions(+) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt index 7e7c27a50..4207a37d6 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/FreezeSettingsUI.kt @@ -2,16 +2,21 @@ package com.sameerasw.essentials.ui.composables.configs import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -34,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -76,6 +82,11 @@ fun FreezeSettingsUI( var isMenuExpanded by remember { mutableStateOf(false) } + val pagerState = rememberPagerState(pageCount = { 2 }) + LaunchedEffect(viewModel.freezeMode.intValue) { + pagerState.animateScrollToPage(viewModel.freezeMode.intValue) + } + val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/json") ) { uri -> @@ -322,6 +333,93 @@ fun FreezeSettingsUI( ) }, ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + ) + .padding(8.dp) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { page -> + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(20.dp) + ) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (page == 0) { + Text( + text = stringResource(R.string.freeze_mode_description_freeze_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.freeze_mode_description_freeze_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.freeze_mode_description_freeze_warning), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } else { + Text( + text = stringResource(R.string.freeze_mode_description_suspend_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.freeze_mode_description_suspend_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.freeze_mode_description_suspend_footer), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } + } + + // Pagination Indicators + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(2) { iteration -> + val isActive = pagerState.currentPage == iteration + val color by animateColorAsState( + targetValue = if (isActive) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + label = "dotColor" + ) + + Box( + modifier = Modifier + .padding(4.dp) + .size(if (isActive) 8.dp else 6.dp) + .background(color, CircleShape) + ) + } + } + } } Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 911dbed1f..05055b7f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -121,6 +121,13 @@ Remove Create shortcut App info + What is Freeze? + App freezing disables the app\'s launch activity which removes it from the app list and updates. It will prevent the app from starting at all until it\'s unfrozen which saves resources but you will need to unfreeze from here or manually re-enable. + DO NOT FREEZE COMMUNICATION APPS + + What is Suspend? + Suspending an app used to pause the app activity and prevent background executions but with recent Android changes, it only pauses the notifications from appearing and that\'s pretty much it. But does allows you to unpause from the launcher app list as they will be still available as grayscale paused app icons. + Should work the same as the native app pause/ focus mode features. More options Freeze all apps Unfreeze all apps From c4626e2a71e14e7b260539777ae1f66e20caa992 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 20:02:50 +0530 Subject: [PATCH 10/17] feat: add Pixel diagnostics and device check tools --- .../ui/components/DeviceHeroCard.kt | 184 ++++++++++++++++++ app/src/main/res/drawable/flashbang.gif | Bin 0 -> 41224 bytes .../res/drawable/rounded_diagnosis_24.xml | 5 + .../drawable/rounded_search_check_2_24.xml | 5 + app/src/main/res/values/strings.xml | 7 + 5 files changed, 201 insertions(+) create mode 100644 app/src/main/res/drawable/flashbang.gif create mode 100644 app/src/main/res/drawable/rounded_diagnosis_24.xml create mode 100644 app/src/main/res/drawable/rounded_search_check_2_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt index 32f08e696..6ad7e155d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/DeviceHeroCard.kt @@ -34,11 +34,34 @@ import coil.compose.AsyncImage import com.sameerasw.essentials.R import com.sameerasw.essentials.data.model.DeviceSpecs import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.Dialog +import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.request.ImageRequest import com.sameerasw.essentials.ui.theme.Shapes import com.sameerasw.essentials.ui.components.modifiers.shimmer import com.sameerasw.essentials.utils.DeviceInfo import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.DeviceImageMapper +import com.sameerasw.essentials.utils.HapticUtil @Composable fun DeviceHeroCard( @@ -49,9 +72,35 @@ fun DeviceHeroCard( contentOffset: () -> Dp = { 0.dp }, modifier: Modifier = Modifier ) { + val context = LocalContext.current + val view = LocalView.current val imageUrls = deviceSpecs?.imageUrls ?: emptyList() val isPixel = deviceInfo.manufacturer.contains("Google", ignoreCase = true) + var showFlashbangDialog by remember { mutableStateOf(false) } + + val launchIntent = { packageName: String, className: String -> + try { + val intent = Intent(Intent.ACTION_MAIN).apply { + component = ComponentName(packageName, className) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + + } + } + + if (showFlashbangDialog) { + FlashbangDialog( + onDismiss = { showFlashbangDialog = false }, + onContinue = { + showFlashbangDialog = false + launchIntent("com.google.android.apps.diagnosticstool", "com.google.android.apps.diagnosticstool.login.EndUserLoginActivity") + } + ) + } + // Only show the illustration page if it's a Pixel AND we have a mapping val illustrationRes = DeviceImageMapper.getDeviceDrawable(deviceInfo.model) val showIllustration = isPixel && illustrationRes != 0 @@ -310,6 +359,141 @@ fun DeviceHeroCard( } } } + } + + if (isPixel) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceBright, + shape = Shapes.extraSmall + ) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + PixelToolButton( + iconRes = R.drawable.rounded_diagnosis_24, + label = stringResource(id = R.string.label_diagnostics), + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + launchIntent("com.android.devicediagnostics", "com.android.devicediagnostics.MainActivity") + } + ) + PixelToolButton( + iconRes = R.drawable.rounded_search_check_2_24, + label = stringResource(id = R.string.label_device_check), + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + showFlashbangDialog = true + } + ) + } + } + } +} + +@Composable +private fun PixelToolButton( + iconRes: Int, + label: String, + onClick: () -> Unit +) { + androidx.compose.material3.Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp) + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) } } + +@Composable +private fun FlashbangDialog( + onDismiss: () -> Unit, + onContinue: () -> Unit +) { + val context = LocalContext.current + val view = LocalView.current + val imageLoader = remember { + ImageLoader.Builder(context) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() + } + + androidx.compose.material3.AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(id = R.string.label_device_check), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.msg_flashbang), + style = MaterialTheme.typography.bodyLarge, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + AsyncImage( + model = ImageRequest.Builder(context) + .data(R.drawable.flashbang) + .build(), + imageLoader = imageLoader, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(MaterialTheme.shapes.medium), + contentScale = ContentScale.Crop + ) + } + }, + confirmButton = { + androidx.compose.material3.Button( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onContinue() + } + ) { + Text(text = stringResource(id = R.string.action_continue)) + } + }, + dismissButton = { + androidx.compose.material3.OutlinedButton( + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onDismiss() + } + ) { + Text(text = stringResource(id = R.string.action_abort)) + } + } + ) +} diff --git a/app/src/main/res/drawable/flashbang.gif b/app/src/main/res/drawable/flashbang.gif new file mode 100644 index 0000000000000000000000000000000000000000..7aab74c5a00c142622a12292a5fdfffdad421add GIT binary patch literal 41224 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 Q=wpFhwp* + + + + diff --git a/app/src/main/res/drawable/rounded_search_check_2_24.xml b/app/src/main/res/drawable/rounded_search_check_2_24.xml new file mode 100644 index 000000000..a185dd830 --- /dev/null +++ b/app/src/main/res/drawable/rounded_search_check_2_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05055b7f5..be0a67796 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1294,4 +1294,11 @@ Feedback sent successfully! Thanks for helping us improve the app. Alternatively + + Diagnostics + Device Check + Get ready to be flashbanged! + Abort + Continue + \ No newline at end of file From 49cb6c4bc1d2a56b7cdf7ed53db9c46bb2d7a14a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 20:51:12 +0530 Subject: [PATCH 11/17] refactor: decouple AppFreezingActivity by delegating UI logic to FreezeGridUI and integrating with MainViewModel. --- .../com/sameerasw/essentials/MainActivity.kt | 5 + .../ui/activities/AppFreezingActivity.kt | 436 +++--------------- .../essentials/ui/composables/FreezeGridUI.kt | 23 +- 3 files changed, 84 insertions(+), 380 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt index 847d58508..c85a7ca7d 100644 --- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt @@ -643,6 +643,11 @@ class MainActivity : AppCompatActivity() { startActivity(Intent(context, FeatureSettingsActivity::class.java).apply { putExtra("feature", "Freeze") }) + }, + onSettingsClick = { + startActivity(Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Freeze") + }) } ) } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt index 180ac8985..028407f26 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AppFreezingActivity.kt @@ -4,91 +4,37 @@ import android.content.Intent import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -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.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.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FloatingActionButtonMenu -import androidx.compose.material3.FloatingActionButtonMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.ToggleFloatingActionButton -import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.FeatureSettingsActivity -import com.sameerasw.essentials.R -import com.sameerasw.essentials.domain.model.NotificationApp -import com.sameerasw.essentials.ui.components.ReusableTopAppBar -import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.composables.FreezeGridUI +import com.sameerasw.essentials.ui.modifiers.BlurDirection +import com.sameerasw.essentials.ui.modifiers.progressiveBlur +import com.sameerasw.essentials.ui.state.LocalMenuStateManager +import com.sameerasw.essentials.ui.state.MenuStateManager import com.sameerasw.essentials.ui.theme.EssentialsTheme -import com.sameerasw.essentials.utils.FreezeManager -import com.sameerasw.essentials.utils.HapticUtil -import com.sameerasw.essentials.utils.ShortcutUtil -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.sameerasw.essentials.viewmodels.MainViewModel -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) class AppFreezingActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { @@ -100,336 +46,68 @@ class AppFreezingActivity : ComponentActivity() { window.isNavigationBarContrastEnforced = false } - setContent { - val viewModel: com.sameerasw.essentials.viewmodels.MainViewModel = - androidx.lifecycle.viewmodel.compose.viewModel() + val viewModel: MainViewModel = viewModel() val context = LocalContext.current + LaunchedEffect(Unit) { viewModel.check(context) + viewModel.refreshFreezePickedApps(context) } - val isPitchBlackThemeEnabled by viewModel.isPitchBlackThemeEnabled - EssentialsTheme(pitchBlackTheme = isPitchBlackThemeEnabled) { - val context = LocalContext.current - val view = LocalView.current - val pickedApps by viewModel.freezePickedApps - val isPickedAppsLoading by viewModel.isFreezePickedAppsLoading - - val gridState = rememberLazyGridState() - val scrollBehavior = - TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) - val frozenStates = remember { mutableStateMapOf() } - val lifecycleOwner = LocalLifecycleOwner.current - - // Refresh frozen states when activity gains focus - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - viewModel.check(context) - viewModel.refreshFreezePickedApps(context) - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(pickedApps) { - withContext(Dispatchers.IO) { - pickedApps.forEach { app -> - frozenStates[app.packageName] = - FreezeManager.isAppFrozen(context, app.packageName) - } - } - } - Scaffold( - contentWindowInsets = WindowInsets(0, 0, 0, 0), - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - containerColor = MaterialTheme.colorScheme.surfaceContainer, - topBar = { - ReusableTopAppBar( - title = getString(R.string.freeze_activity_title), - subtitle = getString(R.string.freeze_activity_subtitle), - hasBack = false, - scrollBehavior = scrollBehavior, - actions = { - IconButton(onClick = { - HapticUtil.performVirtualKeyHaptic(view) - val intent = - Intent(context, FeatureSettingsActivity::class.java).apply { - putExtra("feature", "Freeze") - } - context.startActivity(intent) - }) { - Icon( - painter = painterResource(id = R.drawable.rounded_settings_24), - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.primary - ) - } - } - ) - }, - floatingActionButton = { - ExpandableFreezeFab( - modifier = Modifier.padding(bottom = 16.dp, end = 16.dp), - onUnfreezeAll = { viewModel.unfreezeAllApps(context) }, - onFreezeAll = { viewModel.freezeAllApps(context) }, - onFreezeAutomatic = { viewModel.freezeAutomaticApps(context) } - ) - } - ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - if (isPickedAppsLoading && pickedApps.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - LoadingIndicator() - } - } else if (pickedApps.isEmpty()) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_mode_cool_24), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.msg_no_apps_frozen), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - androidx.compose.material3.Button( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - val intent = Intent(context, FeatureSettingsActivity::class.java).apply { - putExtra("feature", "Freeze") - } - context.startActivity(intent) - } - ) { - Text(stringResource(R.string.action_get_started)) - } - } - } else { - RoundedCardContainer( - modifier = Modifier - .padding(16.dp) - ) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 88.dp), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 88.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(pickedApps, key = { it.packageName }) { app -> - AppGridItem( - app = app, - isFrozen = frozenStates[app.packageName] ?: false, - isAutoFreezeEnabled = app.isEnabled, - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - viewModel.launchAndUnfreezeApp( - context, - app.packageName - ) - // Finish after launch - finish() - }, - onLongClick = { - ShortcutUtil.pinAppShortcut(context, app) - } - ) - } - } - } - } - } - } - } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun AppGridItem( - app: NotificationApp, - isFrozen: Boolean, - isAutoFreezeEnabled: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit -) { - val view = LocalView.current - val grayscaleMatrix = remember { ColorMatrix().apply { setToSaturation(0.4f) } } - - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceBright, - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - HapticUtil.performVirtualKeyHaptic(view) - onClick() - }, - onLongClick = { - HapticUtil.performVirtualKeyHaptic(view) - onLongClick() - } - ) - ) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(56.dp) - ) { - // App Icon - Image( - bitmap = app.icon, - contentDescription = app.appName, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(14.dp)), - contentScale = ContentScale.Fit, - colorFilter = if (isFrozen) ColorFilter.colorMatrix(grayscaleMatrix) else null, - alpha = if (isFrozen) 0.6f else 1f - ) + val isPitchBlackThemeEnabled by viewModel.isPitchBlackThemeEnabled + val isBlurEnabled by viewModel.isBlurEnabled - // Status Badges (Top Right) - Row( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 4.dp, y = (-4).dp), - horizontalArrangement = Arrangement.spacedBy((-4).dp) + EssentialsTheme(pitchBlackTheme = isPitchBlackThemeEnabled) { + CompositionLocalProvider( + LocalMenuStateManager provides remember { MenuStateManager() } ) { - // Auto-freeze Exclusion Badge (Lock) - if (!isAutoFreezeEnabled) { + Scaffold( + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { innerPadding -> + val density = LocalDensity.current + val statusBarHeightPx = with(density) { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding().toPx() + } + Box( modifier = Modifier - .size(20.dp) - .background(MaterialTheme.colorScheme.error, CircleShape) - .padding(4.dp) + .fillMaxSize() + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = statusBarHeightPx * 1.15f, + direction = BlurDirection.TOP + ) + .progressiveBlur( + blurRadius = if (isBlurEnabled) 40f else 0f, + height = with(density) { 80.dp.toPx() }, + direction = BlurDirection.BOTTOM + ) ) { - Icon( - painter = painterResource(id = R.drawable.rounded_lock_clock_24), - contentDescription = "Auto-freeze excluded", + FreezeGridUI( + viewModel = viewModel, modifier = Modifier.fillMaxSize(), - tint = MaterialTheme.colorScheme.onError + contentPadding = PaddingValues( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding(), + bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 130.dp, + start = 0.dp, + end = 0.dp + ), + onAppLaunched = { + finish() + }, + onSettingsClick = { + val intent = Intent(context, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Freeze") + } + context.startActivity(intent) + } ) } } } } - - Spacer(modifier = Modifier.height(10.dp)) - - Text( - text = app.appName, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = if (isFrozen) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface - ) } } } - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun ExpandableFreezeFab( - modifier: Modifier = Modifier, - onUnfreezeAll: () -> Unit, - onFreezeAll: () -> Unit, - onFreezeAutomatic: () -> Unit -) { - var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } - - BackHandler(fabMenuExpanded) { fabMenuExpanded = false } - - FloatingActionButtonMenu( - modifier = modifier, - expanded = fabMenuExpanded, - button = { - ToggleFloatingActionButton( - modifier = Modifier - .semantics { - stateDescription = if (fabMenuExpanded) "Expanded" else "Collapsed" - contentDescription = "Toggle menu" - }, - checked = fabMenuExpanded, - onCheckedChange = { fabMenuExpanded = !fabMenuExpanded }, - ) { - Icon( - painter = painterResource( - id = if (checkedProgress > 0.5f) R.drawable.rounded_close_24 else R.drawable.rounded_mode_cool_24 - ), - contentDescription = null, - modifier = Modifier.animateIcon({ checkedProgress }), - ) - } - }, - ) { - FloatingActionButtonMenuItem( - onClick = { - fabMenuExpanded = false - onFreezeAll() - }, - icon = { - Icon( - painterResource(id = R.drawable.rounded_mode_cool_24), - contentDescription = null - ) - }, - text = { Text(text = "Freeze All") }, - ) - FloatingActionButtonMenuItem( - onClick = { - fabMenuExpanded = false - onUnfreezeAll() - }, - icon = { - Icon( - painterResource(id = R.drawable.rounded_mode_cool_off_24), - contentDescription = null - ) - }, - text = { Text(text = "Unfreeze All") }, - ) - FloatingActionButtonMenuItem( - onClick = { - fabMenuExpanded = false - onFreezeAutomatic() - }, - icon = { - Icon( - painterResource(id = R.drawable.rounded_nest_farsight_cool_24), - contentDescription = null - ) - }, - text = { Text(text = "Freeze Automatic") }, - ) - } -} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt index 46d392974..f5608af42 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/FreezeGridUI.kt @@ -103,7 +103,9 @@ fun FreezeGridUI( viewModel: MainViewModel, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), - onGetStartedClick: (() -> Unit)? = null + onGetStartedClick: (() -> Unit)? = null, + onAppLaunched: (() -> Unit)? = null, + onSettingsClick: (() -> Unit)? = null ) { val context = LocalContext.current val view = LocalView.current @@ -290,6 +292,7 @@ fun FreezeGridUI( bestMatch?.let { app -> HapticUtil.performVirtualKeyHaptic(view) viewModel.launchAndUnfreezeApp(context, app.packageName) + onAppLaunched?.invoke() } } ) @@ -429,6 +432,23 @@ fun FreezeGridUI( ) } ) + onSettingsClick?.let { onSettings -> + SegmentedDropdownMenuItem( + text = { Text(stringResource(R.string.label_settings)) }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + onSettings() + isMenuExpanded = false + }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.rounded_settings_heart_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + } } } } @@ -489,6 +509,7 @@ fun FreezeGridUI( context, app.packageName ) + onAppLaunched?.invoke() }, onToggleFreeze = { scope.launch(Dispatchers.IO) { From 310af0fb93bd8ffaaabc359316c5c61cc1035323 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 21:08:22 +0530 Subject: [PATCH 12/17] fix: Are we there yet title text color fix --- .../ui/composables/configs/LocationReachedSettingsUI.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt index 905a0f745..b397290f8 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt @@ -171,7 +171,8 @@ fun LocationReachedSettingsUI( Text( text = stringResource(R.string.location_reached_dest_ready), style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( From 05c6fa7416ffbe5d21930c3c7c8f8f8974a9a913 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 22:26:25 +0530 Subject: [PATCH 13/17] feat: Saved location support and major improvement sfor the are we there yet feature --- app/src/main/AndroidManifest.xml | 10 + .../essentials/FeatureSettingsActivity.kt | 8 +- .../essentials/LinkPickerActivity.kt | 10 + .../repository/LocationReachedRepository.kt | 125 +++- .../essentials/domain/model/LocationAlarm.kt | 5 +- .../services/LocationReachedService.kt | 27 +- .../tiles/LocationReachedTileService.kt | 69 +++ .../ui/activities/LocationAlarmActivity.kt | 10 +- .../ui/activities/QSPreferencesActivity.kt | 1 + .../ui/components/cards/LocationAlarmCard.kt | 90 +++ .../sheets/LocationReachedBottomSheet.kt | 189 ++++++ .../configs/LocationReachedSettingsUI.kt | 562 ++++++++---------- .../configs/QuickSettingsTilesSettingsUI.kt | 7 + .../viewmodels/LocationReachedViewModel.kt | 230 ++++--- .../main/res/drawable/rounded_history_24.xml | 5 + app/src/main/res/values/strings.xml | 19 +- 16 files changed, 926 insertions(+), 441 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/tiles/LocationReachedTileService.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt create mode 100644 app/src/main/res/drawable/rounded_history_24.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b713facb1..f6e40f793 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -573,6 +573,16 @@ + + + + + { LocationReachedSettingsUI( mainViewModel = viewModel, - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier.fillMaxSize(), highlightSetting = highlightSetting ) } @@ -637,7 +637,7 @@ class FeatureSettingsActivity : AppCompatActivity() { } // Bottom padding for toolbar - if (featureId != "Quick settings tiles") { + if (featureId != "Quick settings tiles" && featureId != "Location reached") { androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(150.dp)) } } diff --git a/app/src/main/java/com/sameerasw/essentials/LinkPickerActivity.kt b/app/src/main/java/com/sameerasw/essentials/LinkPickerActivity.kt index c890fd3f1..199d26b7e 100644 --- a/app/src/main/java/com/sameerasw/essentials/LinkPickerActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/LinkPickerActivity.kt @@ -16,6 +16,16 @@ class LinkPickerActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val locationViewModel = com.sameerasw.essentials.viewmodels.LocationReachedViewModel(application) + if (locationViewModel.handleIntent(intent)) { + val settingsIntent = Intent(this, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Location reached") + } + startActivity(settingsIntent) + finish() + return + } + val uri = when (intent.action) { Intent.ACTION_SEND -> { val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt index 8ce23e930..7cfc385c4 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt @@ -2,26 +2,79 @@ package com.sameerasw.essentials.data.repository import android.content.Context import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import com.sameerasw.essentials.domain.model.LocationAlarm - import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID class LocationReachedRepository(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + private val gson = Gson() companion object { private val _isProcessing = MutableStateFlow(false) val isProcessing = _isProcessing.asStateFlow() - private val _alarmFlow = MutableStateFlow(null) - val alarmFlow = _alarmFlow.asStateFlow() + private val _alarmsFlow = MutableStateFlow>(emptyList()) + val alarmsFlow = _alarmsFlow.asStateFlow() + + private val _activeAlarmId = MutableStateFlow(null) + val activeAlarmId = _activeAlarmId.asStateFlow() + + private val _tempAlarm = MutableStateFlow(null) + val tempAlarm = _tempAlarm.asStateFlow() + + private val _showBottomSheet = MutableStateFlow(false) + val showBottomSheet = _showBottomSheet.asStateFlow() + } + + fun setTempAlarm(alarm: LocationAlarm?) { + _tempAlarm.value = alarm + } + + fun setShowBottomSheet(show: Boolean) { + _showBottomSheet.value = show } init { - if (_alarmFlow.value == null) { - _alarmFlow.value = getAlarm() + migrateIfNeeded() + _alarmsFlow.value = getAlarms() + _activeAlarmId.value = getActiveAlarmId() + } + + private fun migrateIfNeeded() { + if (prefs.contains("location_reached_lat") && !prefs.contains("location_reached_alarms_json")) { + val lat = java.lang.Double.longBitsToDouble(prefs.getLong("location_reached_lat", 0L)) + val lng = java.lang.Double.longBitsToDouble(prefs.getLong("location_reached_lng", 0L)) + val radius = prefs.getInt("location_reached_radius", 1000) + val enabled = prefs.getBoolean("location_reached_enabled", false) + + if (lat != 0.0 || lng != 0.0) { + val migratedAlarm = LocationAlarm( + id = UUID.randomUUID().toString(), + name = "Migrated Destination", + latitude = lat, + longitude = lng, + radius = radius, + isEnabled = enabled + ) + saveAlarms(listOf(migratedAlarm)) + if (enabled) { + saveActiveAlarmId(migratedAlarm.id) + } + } + + // Clear old prefs + prefs.edit().apply { + remove("location_reached_lat") + remove("location_reached_lng") + remove("location_reached_radius") + remove("location_reached_enabled") + apply() + } } } @@ -29,33 +82,47 @@ class LocationReachedRepository(context: Context) { _isProcessing.value = processing } - fun saveAlarm(alarm: LocationAlarm) { - prefs.edit().apply { - putLong("location_reached_lat", java.lang.Double.doubleToRawLongBits(alarm.latitude)) - putLong("location_reached_lng", java.lang.Double.doubleToRawLongBits(alarm.longitude)) - putInt("location_reached_radius", alarm.radius) - putBoolean("location_reached_enabled", alarm.isEnabled) - apply() + fun saveAlarms(alarms: List) { + val json = gson.toJson(alarms) + prefs.edit().putString("location_reached_alarms_json", json).apply() + _alarmsFlow.value = alarms + } + + fun getAlarms(): List { + val json = prefs.getString("location_reached_alarms_json", null) ?: return emptyList() + val type = object : TypeToken>() {}.type + return try { + gson.fromJson(json, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } + + fun saveActiveAlarmId(id: String?) { + prefs.edit().putString("location_reached_active_id", id).apply() + _activeAlarmId.value = id + } + + fun getActiveAlarmId(): String? { + return prefs.getString("location_reached_active_id", null) + } + + fun saveLastTrip(alarm: LocationAlarm?) { + if (alarm == null) { + prefs.edit().remove("location_reached_last_trip_json").apply() + } else { + val json = gson.toJson(alarm) + prefs.edit().putString("location_reached_last_trip_json", json).apply() } - _alarmFlow.value = alarm } - fun getAlarm(): LocationAlarm { - val lat = java.lang.Double.longBitsToDouble( - prefs.getLong( - "location_reached_lat", - java.lang.Double.doubleToRawLongBits(0.0) - ) - ) - val lng = java.lang.Double.longBitsToDouble( - prefs.getLong( - "location_reached_lng", - java.lang.Double.doubleToRawLongBits(0.0) - ) - ) - val radius = prefs.getInt("location_reached_radius", 1000) - val enabled = prefs.getBoolean("location_reached_enabled", false) - return LocationAlarm(lat, lng, radius, enabled) + fun getLastTrip(): LocationAlarm? { + val json = prefs.getString("location_reached_last_trip_json", null) ?: return null + return try { + gson.fromJson(json, LocationAlarm::class.java) + } catch (e: Exception) { + null + } } fun saveStartDistance(distance: Float) { diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt index f94969473..bd0a37f19 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt @@ -5,8 +5,11 @@ import com.google.gson.annotations.SerializedName @Keep data class LocationAlarm( + @SerializedName("id") val id: String = java.util.UUID.randomUUID().toString(), + @SerializedName("name") val name: String = "", @SerializedName("latitude") val latitude: Double = 0.0, @SerializedName("longitude") val longitude: Double = 0.0, @SerializedName("radius") val radius: Int = 1000, // in meters - @SerializedName("isEnabled") val isEnabled: Boolean = false + @SerializedName("isEnabled") val isEnabled: Boolean = false, + @SerializedName("createdAt") val createdAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt index 259597a4a..d40504b61 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt @@ -16,6 +16,7 @@ import com.google.android.gms.location.Priority import com.sameerasw.essentials.MainActivity import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.LocationReachedRepository +import com.sameerasw.essentials.domain.model.LocationAlarm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -79,26 +80,36 @@ class LocationReachedService : Service() { trackingJob?.cancel() trackingJob = serviceScope.launch { while (isActive) { - val alarm = repository.getAlarm() - if (alarm.isEnabled && alarm.latitude != 0.0 && alarm.longitude != 0.0) { + val activeId = repository.getActiveAlarmId() + val alarms = repository.getAlarms() + val alarm = alarms.find { it.id == activeId } + + if (alarm != null) { updateProgress(alarm) } else { stopSelf() break } - delay(10000) // Update every 10 seconds for better responsiveness + delay(10000) } } } private fun stopTracking() { - val alarm = repository.getAlarm() - repository.saveAlarm(alarm.copy(isEnabled = false)) + val activeId = repository.getActiveAlarmId() + val alarms = repository.getAlarms() + val alarm = alarms.find { it.id == activeId } + + if (alarm != null) { + repository.saveLastTrip(alarm) + } + + repository.saveActiveAlarmId(null) stopSelf() } @android.annotation.SuppressLint("MissingPermission") - private fun updateProgress(alarm: com.sameerasw.essentials.domain.model.LocationAlarm) { + private fun updateProgress(alarm: LocationAlarm) { fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) .addOnSuccessListener { location -> location?.let { @@ -292,11 +303,11 @@ class LocationReachedService : Service() { val channel = NotificationChannel( CHANNEL_ID, getString(R.string.location_reached_channel_name), - NotificationManager.IMPORTANCE_HIGH // Increased importance + NotificationManager.IMPORTANCE_HIGH ).apply { description = getString(R.string.location_reached_channel_desc) setShowBadge(false) - lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Ensure it's visible on lockscreen + lockscreenVisibility = Notification.VISIBILITY_PUBLIC setSound(null, null) enableVibration(false) } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/LocationReachedTileService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/LocationReachedTileService.kt new file mode 100644 index 000000000..591b0828b --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/LocationReachedTileService.kt @@ -0,0 +1,69 @@ +package com.sameerasw.essentials.services.tiles + +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import androidx.annotation.RequiresApi +import com.sameerasw.essentials.FeatureSettingsActivity +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.LocationReachedRepository +import com.sameerasw.essentials.services.LocationReachedService +import com.sameerasw.essentials.utils.PermissionUtils + +@RequiresApi(Build.VERSION_CODES.N) +class LocationReachedTileService : BaseTileService() { + private lateinit var repository: LocationReachedRepository + + override fun onCreate() { + super.onCreate() + repository = LocationReachedRepository(this) + } + + override fun onTileClick() { + val activeId = LocationReachedRepository.activeAlarmId.value + val alarms = repository.getAlarms() + + if (activeId != null) { + // Stop tracking + repository.saveActiveAlarmId(null) + LocationReachedService.stop(this) + } else { + // Start tracking for the last trip or the first alarm + val lastTrip = repository.getLastTrip() + val targetAlarm = lastTrip ?: alarms.firstOrNull() + + if (targetAlarm != null) { + repository.saveActiveAlarmId(targetAlarm.id) + LocationReachedService.start(this) + } else { + // No alarms to start, open settings + val intent = Intent(this, FeatureSettingsActivity::class.java).apply { + putExtra("feature", "Location reached") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } + } + + override fun getTileLabel(): String = getString(R.string.tile_location_reached) + + override fun getTileSubtitle(): String { + val activeId = LocationReachedRepository.activeAlarmId.value + return if (activeId != null) { + repository.getAlarms().find { it.id == activeId }?.name ?: "Tracking" + } else { + "Idle" + } + } + + override fun hasFeaturePermission(): Boolean { + return PermissionUtils.hasLocationPermission(this) && + PermissionUtils.hasBackgroundLocationPermission(this) + } + + override fun getTileState(): Int { + return if (LocationReachedRepository.activeAlarmId.value != null) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt index e96eaecae..8fd66769f 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt @@ -145,8 +145,14 @@ class LocationAlarmActivity : ComponentActivity() { // Disable alarm in repo val repo = LocationReachedRepository(this) - val alarm = repo.getAlarm() - repo.saveAlarm(alarm.copy(isEnabled = false)) + val activeId = repo.getActiveAlarmId() + val alarms = repo.getAlarms() + val alarm = alarms.find { it.id == activeId } + + if (alarm != null) { + repo.saveLastTrip(alarm) + } + repo.saveActiveAlarmId(null) // Stop the progress service LocationReachedService.stop(this) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt index e4b958021..5747fb51d 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/QSPreferencesActivity.kt @@ -85,6 +85,7 @@ class QSPreferencesActivity : ComponentActivity() { "com.sameerasw.essentials.services.tiles.BatteryNotificationTileService" -> "Battery notification" "com.sameerasw.essentials.services.tiles.ChargeQuickTileService" -> "Quick settings tiles" "com.sameerasw.essentials.services.tiles.AlwaysOnDisplayTileService" -> "Always on Display" + "com.sameerasw.essentials.services.tiles.LocationReachedTileService" -> "Location reached" else -> null } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt new file mode 100644 index 000000000..f352cee38 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt @@ -0,0 +1,90 @@ +package com.sameerasw.essentials.ui.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.LocationAlarm + +@Composable +fun LocationAlarmCard( + alarm: LocationAlarm, + isActive: Boolean, + isAnyTracking: Boolean, + onStart: () -> Unit, + onStop: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = androidx.compose.ui.platform.LocalView.current + + ListItem( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceBright, MaterialTheme.shapes.extraSmall) + .clickable { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onClick() + }, + headlineContent = { + Text( + text = alarm.name.ifEmpty { "Destination" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + }, + supportingContent = { + Text( + text = "Radius: ${alarm.radius}m", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + trailingContent = { + if (isActive) { + IconButton( + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onStop() + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + painter = painterResource(R.drawable.rounded_close_24), + contentDescription = "Stop" + ) + } + } else if (!isAnyTracking) { + IconButton( + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onStart() + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.rounded_play_arrow_24), + contentDescription = "Start" + ) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent) + ) +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt new file mode 100644 index 000000000..6ca3f7d91 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt @@ -0,0 +1,189 @@ +package com.sameerasw.essentials.ui.components.sheets + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.domain.model.LocationAlarm +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.viewmodels.LocationReachedViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun LocationReachedBottomSheet( + viewModel: LocationReachedViewModel, + onDismissRequest: () -> Unit +) { + val tempAlarm by viewModel.tempAlarm + val currentAlarm = tempAlarm + val distance by viewModel.currentDistance + val view = androidx.compose.ui.platform.LocalView.current + + val isProcessing by viewModel.isProcessingCoordinates + + if (currentAlarm == null && !isProcessing) return + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + dragHandle = { BottomSheetDefaults.DragHandle() } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = if (currentAlarm != null && viewModel.savedAlarms.value.any { it.id == currentAlarm.id }) + stringResource(R.string.location_reached_edit_title) + else stringResource(R.string.location_reached_add_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + if (isProcessing && currentAlarm == null) { + RoundedCardContainer( + containerColor = MaterialTheme.colorScheme.surfaceBright, + modifier = Modifier.fillMaxWidth().height(200.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + LoadingIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.location_reached_resolving), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else if (currentAlarm != null) { + RoundedCardContainer( + containerColor = MaterialTheme.colorScheme.surfaceBright, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = currentAlarm.name, + onValueChange = { viewModel.setTempAlarm(currentAlarm.copy(name = it)) }, + label = { Text(stringResource(R.string.location_reached_name_label)) }, + placeholder = { Text(stringResource(R.string.location_reached_name_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = MaterialTheme.shapes.large + ) + + // Coordinates Display + Column { + Text( + text = "Coordinates", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "%.5f, %.5f".format(currentAlarm.latitude, currentAlarm.longitude), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } + + // Radius Slider + Column { + Text( + text = stringResource(R.string.location_reached_radius_label, currentAlarm.radius), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Slider( + value = currentAlarm.radius.toFloat(), + onValueChange = { + if (it.toInt() != currentAlarm.radius) { + com.sameerasw.essentials.utils.HapticUtil.performSliderHaptic(view) + } + viewModel.setTempAlarm(currentAlarm.copy(radius = it.toInt())) + }, + valueRange = 100f..5000f, + steps = 49 + ) + } + } + } + } + + val isEditing = currentAlarm != null && viewModel.savedAlarms.value.any { it.id == currentAlarm.id } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isEditing) { + IconButton( + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + viewModel.deleteAlarm(currentAlarm.id) + onDismissRequest() + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.size(56.dp) // Slightly larger for better touch target + ) { + Icon( + painter = painterResource(R.drawable.rounded_delete_24), + contentDescription = stringResource(R.string.action_delete) + ) + } + } + + OutlinedButton( + onClick = { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + onDismissRequest() + }, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Text(stringResource(R.string.location_reached_cancel_btn)) + } + + Button( + onClick = { + val alarm = tempAlarm + if (alarm != null) { + com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) + viewModel.saveAlarm(alarm) + } + }, + enabled = currentAlarm != null, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Text(stringResource(R.string.location_reached_save_btn)) + } + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt index b397290f8..c60b9f931 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt @@ -1,32 +1,21 @@ package com.sameerasw.essentials.ui.composables.configs -import android.content.Intent -import android.net.Uri import android.os.Build import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text +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.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -34,12 +23,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.sameerasw.essentials.R -import com.sameerasw.essentials.ui.components.cards.IconToggleItem +import com.sameerasw.essentials.ui.components.cards.LocationAlarmCard import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer +import com.sameerasw.essentials.ui.components.sheets.LocationReachedBottomSheet +import com.sameerasw.essentials.ui.components.sheets.PermissionsBottomSheet +import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.viewmodels.LocationReachedViewModel import com.sameerasw.essentials.viewmodels.MainViewModel -@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LocationReachedSettingsUI( mainViewModel: MainViewModel, @@ -48,10 +40,14 @@ fun LocationReachedSettingsUI( ) { val context = LocalContext.current val locationViewModel: LocationReachedViewModel = viewModel() - val alarm by locationViewModel.alarm + val savedAlarms by locationViewModel.savedAlarms + val activeAlarmId by locationViewModel.activeAlarmId + val lastTrip by locationViewModel.lastTrip val distance by locationViewModel.currentDistance - val isProcessing by locationViewModel.isProcessingCoordinates val startDistance by locationViewModel.startDistance + val showBottomSheet by locationViewModel.showBottomSheet + val isProcessing by locationViewModel.isProcessingCoordinates + val view = androidx.compose.ui.platform.LocalView.current DisposableEffect(locationViewModel) { locationViewModel.startUiTracking() @@ -60,312 +56,276 @@ fun LocationReachedSettingsUI( } } - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = modifier.fillMaxSize() ) { - if (isProcessing) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LoadingIndicator() - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.location_reached_processing), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + top = statusBarHeight + 8.dp, + bottom = 150.dp, + start = 16.dp, + end = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + // Top Progress / Last Trip Card + item { + val activeAlarm = savedAlarms.find { it.id == activeAlarmId } + TopStatusCard( + activeAlarm = activeAlarm, + lastTrip = lastTrip, + distance = distance, + startDistance = startDistance, + onStop = { locationViewModel.stopTracking() }, + onStart = { + HapticUtil.performVirtualKeyHaptic(view) + locationViewModel.startTracking(it) + } ) } - } else if (alarm.latitude != 0.0 && alarm.longitude != 0.0) { - // Destination Set State - RoundedCardContainer( - modifier = Modifier, - cornerRadius = 28.dp - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp) - ) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - - if (alarm.isEnabled) { - // TRACKING STATE - val distanceText = distance?.let { - if (it < 1000) stringResource( - R.string.location_reached_dist_m, - it.toInt() - ) - else stringResource(R.string.location_reached_dist_km, it / 1000f) - } ?: stringResource(R.string.location_reached_calculating) - - Text( - text = stringResource(R.string.location_reached_dist_remaining), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = distanceText, - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - if (distance != null && startDistance > 0) { - val progress = - (1.0f - (distance!! / startDistance)).coerceIn(0.0f, 1.0f) - Spacer(modifier = Modifier.height(24.dp)) - - LinearWavyProgressIndicator( - progress = { progress }, - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.primaryContainer, - wavelength = 20.dp, - amplitude = { 1.0f } // Normalized amplitude - ) - } - - Spacer(modifier = Modifier.height(32.dp)) + // List Header + if (savedAlarms.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.location_reached_saved_destinations), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + color = MaterialTheme.colorScheme.onSurface + ) + } + } - Button( - onClick = { locationViewModel.stopTracking() }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.error - ), - shape = androidx.compose.foundation.shape.CircleShape - ) { - Icon( - painterResource(R.drawable.rounded_pause_24), - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.location_reached_stop_tracking)) - } + // Destinations List + if (savedAlarms.isNotEmpty()) { + item { + RoundedCardContainer( + modifier = Modifier.fillMaxWidth(), + ) { + savedAlarms.forEachIndexed { index, alarm -> + LocationAlarmCard( + alarm = alarm, + isActive = activeAlarmId == alarm.id, + isAnyTracking = activeAlarmId != null, + onStart = { + HapticUtil.performVirtualKeyHaptic(view) + locationViewModel.startTracking(alarm.id) + }, + onStop = { + HapticUtil.performVirtualKeyHaptic(view) + locationViewModel.stopTracking() + }, + onClick = { + HapticUtil.performVirtualKeyHaptic(view) + locationViewModel.setTempAlarm(alarm) + locationViewModel.setShowBottomSheet(true) + } + ) + } + } + } + } - } else { - // READY STATE (Not Tracking) - Icon( - painter = painterResource(id = R.drawable.rounded_my_location_24), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.location_reached_dest_ready), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) + if (savedAlarms.isEmpty() && !isProcessing) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { Text( - text = "${alarm.latitude}, ${alarm.longitude}", + text = stringResource(R.string.location_reached_no_saved_dest), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) - - Spacer(modifier = Modifier.height(32.dp)) - - Button( - onClick = { locationViewModel.startTracking() }, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ), - shape = androidx.compose.foundation.shape.CircleShape - ) { - Icon( - painterResource(R.drawable.rounded_play_arrow_24), - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.location_reached_start_tracking)) - } } + } + } - Spacer(modifier = Modifier.height(16.dp)) + // Instructional Description + item { + Text( + text = stringResource(R.string.location_reached_instructional_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp) + ) + } - // Secondary Actions - Row( + // Permission Warning + item { + val isFSIGranted by mainViewModel.isFullScreenIntentPermissionGranted + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !isFSIGranted) { + RoundedCardContainer( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + containerColor = MaterialTheme.colorScheme.errorContainer ) { - Button( - onClick = { - val gmmIntentUri = - Uri.parse("geo:${alarm.latitude},${alarm.longitude}") - val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) - mapIntent.setPackage("com.google.android.apps.maps") - try { - context.startActivity(mapIntent) - } catch (e: android.content.ActivityNotFoundException) { - try { - mapIntent.setPackage(null) - context.startActivity(mapIntent) - } catch (ex: android.content.ActivityNotFoundException) { - android.widget.Toast.makeText(context, R.string.error_app_uninstalled, android.widget.Toast.LENGTH_SHORT).show() - } - } - }, - modifier = Modifier.weight(1f), - shape = androidx.compose.foundation.shape.CircleShape, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Icon( - painterResource(R.drawable.rounded_map_24), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.location_reached_view_map)) - } - - Button( - onClick = { locationViewModel.clearAlarm() }, - modifier = Modifier.weight(1f), - shape = androidx.compose.foundation.shape.CircleShape, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Icon( - painterResource(R.drawable.rounded_delete_24), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(stringResource(R.string.location_reached_clear)) - } + com.sameerasw.essentials.ui.components.cards.IconToggleItem( + title = stringResource(R.string.location_reached_fsi_title), + description = stringResource(R.string.location_reached_fsi_desc), + isChecked = false, + onCheckedChange = { mainViewModel.requestFullScreenIntentPermission(context) }, + iconRes = R.drawable.rounded_info_24, + showToggle = false + ) } } } - } else { - // Empty State - RoundedCardContainer( - modifier = Modifier.fillMaxWidth(), - cornerRadius = 28.dp - ) { - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_add_location_alt_24), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.surfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.location_reached_no_dest), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.location_reached_how_to), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center - ) - Spacer(modifier = Modifier.height(24.dp)) - Button( - onClick = { - val gmmIntentUri = Uri.parse("geo:0,0?q=") - val mapIntent = Intent(Intent.ACTION_VIEW, gmmIntentUri) - mapIntent.setPackage("com.google.android.apps.maps") - try { - context.startActivity(mapIntent) - } catch (e: android.content.ActivityNotFoundException) { - try { - mapIntent.setPackage(null) - context.startActivity(mapIntent) - } catch (ex: android.content.ActivityNotFoundException) { - android.widget.Toast.makeText(context, R.string.error_app_uninstalled, android.widget.Toast.LENGTH_SHORT).show() - } - } - }, - shape = androidx.compose.foundation.shape.CircleShape, - colors = ButtonDefaults.filledTonalButtonColors() - ) { - Text(stringResource(R.string.location_reached_open_maps)) - } - } + + item { + Spacer(modifier = Modifier.height(100.dp)) } } + } - Spacer(modifier = Modifier.height(24.dp)) + if (showBottomSheet) { + LocationReachedBottomSheet( + viewModel = locationViewModel, + onDismissRequest = { locationViewModel.setShowBottomSheet(false) } + ) + } +} - Text( - text = stringResource(R.string.location_reached_radius_title, alarm.radius), - style = MaterialTheme.typography.titleMedium, +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TopStatusCard( + activeAlarm: com.sameerasw.essentials.domain.model.LocationAlarm?, + lastTrip: com.sameerasw.essentials.domain.model.LocationAlarm?, + distance: Float?, + startDistance: Float, + onStop: () -> Unit, + onStart: (String) -> Unit +) { + val isTracking = activeAlarm != null + val displayAlarm = activeAlarm ?: lastTrip + + RoundedCardContainer( + modifier = Modifier.fillMaxWidth(), + cornerRadius = 32.dp, + containerColor = MaterialTheme.colorScheme.surfaceBright + ) { + Column( modifier = Modifier .fillMaxWidth() - .padding(start = 16.dp, bottom = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - RoundedCardContainer( - modifier = Modifier, - cornerRadius = 28.dp + .background(MaterialTheme.colorScheme.surfaceBright) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceBright) - .padding(16.dp) - ) { - Slider( - value = alarm.radius.toFloat(), - onValueChange = { newVal -> - locationViewModel.updateAlarm(alarm.copy(radius = newVal.toInt())) - }, - valueRange = 100f..5000f, - steps = 49 + if (isTracking) { + Text( + text = stringResource(R.string.location_reached_tracking_now), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold ) - } - } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = displayAlarm?.name?.ifEmpty { "Destination" } ?: "Destination", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(24.dp)) + + if (distance == null) { + LoadingIndicator() + Spacer(modifier = Modifier.height(16.dp)) + } + + val distanceText = distance?.let { + if (it < 1000) "${it.toInt()} m" else "%.1f km".format(it / 1000f) + } ?: stringResource(R.string.location_reached_calculating) - Spacer(modifier = Modifier.height(24.dp)) + Text( + text = distanceText, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + + if (distance != null && startDistance > 0) { + val progress = (1.0f - (distance / startDistance)).coerceIn(0.0f, 1.0f) + Spacer(modifier = Modifier.height(24.dp)) + LinearWavyProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(12.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + wavelength = 20.dp, + amplitude = { 1.0f } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) - val isFSIGranted by mainViewModel.isFullScreenIntentPermissionGranted - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !isFSIGranted) { - RoundedCardContainer( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.errorContainer, - shape = androidx.compose.foundation.shape.RoundedCornerShape(28.dp) - ), - cornerRadius = 28.dp - ) { - IconToggleItem( - title = stringResource(R.string.location_reached_fsi_title), - description = stringResource(R.string.location_reached_fsi_desc), - isChecked = false, - onCheckedChange = { mainViewModel.requestFullScreenIntentPermission(context) }, - iconRes = R.drawable.rounded_info_24, - showToggle = false + Button( + onClick = onStop, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Icon(painterResource(R.drawable.rounded_close_24), contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.action_stop)) + } + } else if (lastTrip != null) { + Icon( + painter = painterResource(R.drawable.rounded_history_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.location_reached_last_trip), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = lastTrip.name.ifEmpty { "Destination" }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { onStart(lastTrip.id) }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = androidx.compose.foundation.shape.CircleShape + ) { + Text(stringResource(R.string.location_reached_restart_btn)) + } + } else { + // Completely empty state for top card + Icon( + painter = painterResource(R.drawable.rounded_navigation_24), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.feat_location_reached_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } - diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt index a9c23b376..09f8213c7 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/QuickSettingsTilesSettingsUI.kt @@ -289,6 +289,13 @@ fun QuickSettingsTilesSettingsUI( ChargeQuickTileService::class.java, if (ShellUtils.isRootEnabled(context)) listOf("ROOT") else listOf("SHIZUKU"), R.string.about_desc_charge_optimization + ), + QSTileInfo( + R.string.tile_location_reached, + R.drawable.rounded_navigation_24, + com.sameerasw.essentials.services.tiles.LocationReachedTileService::class.java, + listOf("LOCATION", "BACKGROUND_LOCATION", "USE_FULL_SCREEN_INTENT"), + R.string.about_desc_location_reached ) ) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt index 3c3f8a4a8..05cda480c 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt @@ -1,6 +1,8 @@ package com.sameerasw.essentials.viewmodels import android.app.Application +import android.content.Intent +import android.widget.Toast import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -27,7 +29,19 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl private val repository = LocationReachedRepository(application) private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application) - var alarm = mutableStateOf(repository.getAlarm()) + var savedAlarms = mutableStateOf>(emptyList()) + private set + + var activeAlarmId = mutableStateOf(null) + private set + + var lastTrip = mutableStateOf(repository.getLastTrip()) + private set + + var tempAlarm = mutableStateOf(null) + private set + + var showBottomSheet = mutableStateOf(false) private set var isProcessingCoordinates = mutableStateOf(false) @@ -40,11 +54,6 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl private set init { - // Initial distance check - if (alarm.value.latitude != 0.0 && alarm.value.longitude != 0.0) { - updateCurrentDistance() - } - // Observe shared state for real-time updates across activities viewModelScope.launch { LocationReachedRepository.isProcessing.collect { @@ -53,56 +62,108 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl } viewModelScope.launch { - LocationReachedRepository.alarmFlow.collect { newAlarm -> - newAlarm?.let { - alarm.value = it - // Start distance might need refresh if destination changed + LocationReachedRepository.tempAlarm.collect { + tempAlarm.value = it + } + } + + viewModelScope.launch { + LocationReachedRepository.showBottomSheet.collect { + showBottomSheet.value = it + } + } + + viewModelScope.launch { + LocationReachedRepository.alarmsFlow.collect { alarms -> + savedAlarms.value = alarms + } + } + + viewModelScope.launch { + LocationReachedRepository.activeAlarmId.collect { id -> + activeAlarmId.value = id + if (id != null) { + updateCurrentDistance() + } else { + currentDistance.value = null } } } } - fun clearAlarm() { - val clearedAlarm = LocationAlarm(0.0, 0.0, 1000, false) - alarm.value = clearedAlarm - startDistance.value = 0f - repository.saveAlarm(clearedAlarm) - repository.saveStartDistance(0f) - LocationReachedService.stop(getApplication()) - currentDistance.value = null + fun setShowBottomSheet(show: Boolean) { + repository.setShowBottomSheet(show) } - fun startTracking() { - val currentAlarm = alarm.value - if (currentAlarm.latitude != 0.0 && currentAlarm.longitude != 0.0) { - val enabledAlarm = currentAlarm.copy(isEnabled = true) - alarm.value = enabledAlarm - repository.saveAlarm(enabledAlarm) - LocationReachedService.start(getApplication()) - - // Refreshed start distance logic - fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .addOnSuccessListener { location -> - location?.let { - val dist = calculateDistance( - it.latitude, it.longitude, - enabledAlarm.latitude, enabledAlarm.longitude - ) - startDistance.value = dist - repository.saveStartDistance(dist) - } - } + fun setTempAlarm(alarm: LocationAlarm?) { + repository.setTempAlarm(alarm) + } + + fun saveAlarm(alarm: LocationAlarm) { + val currentList = savedAlarms.value.toMutableList() + val index = currentList.indexOfFirst { it.id == alarm.id } + if (index != -1) { + currentList[index] = alarm + } else { + currentList.add(alarm) + } + repository.saveAlarms(currentList) + repository.setShowBottomSheet(false) + repository.setTempAlarm(null) + } + + fun deleteAlarm(alarmId: String) { + if (activeAlarmId.value == alarmId) { + stopTracking() } + val currentList = savedAlarms.value.filter { it.id != alarmId } + repository.saveAlarms(currentList) + } + + fun startTracking(alarmId: String) { + val alarm = savedAlarms.value.find { it.id == alarmId } ?: return + + // Stop any previous tracking + if (activeAlarmId.value != null && activeAlarmId.value != alarmId) { + stopTracking() + } + + repository.saveActiveAlarmId(alarmId) + LocationReachedService.start(getApplication()) + + // Refreshed start distance logic + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) + .addOnSuccessListener { location -> + location?.let { + val dist = calculateDistance( + it.latitude, it.longitude, + alarm.latitude, alarm.longitude + ) + startDistance.value = dist + repository.saveStartDistance(dist) + } + } + + // Clear last trip when starting new + lastTrip.value = null + repository.saveLastTrip(null) } fun stopTracking() { - val currentAlarm = alarm.value - val disabledAlarm = currentAlarm.copy(isEnabled = false) - alarm.value = disabledAlarm - repository.saveAlarm(disabledAlarm) + val id = activeAlarmId.value ?: return + val alarm = savedAlarms.value.find { it.id == id } + + if (alarm != null) { + // Save as last trip + lastTrip.value = alarm + repository.saveLastTrip(alarm) + } + + repository.saveActiveAlarmId(null) LocationReachedService.stop(getApplication()) - // Keep start distance for potential restart? Or maybe just keep coordinates. - // User said "keep last track in memory (only destination)". + currentDistance.value = null + startDistance.value = 0f + repository.saveStartDistance(0f) } private var distanceTrackingJob: kotlinx.coroutines.Job? = null @@ -112,8 +173,7 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl distanceTrackingJob = viewModelScope.launch { while (true) { - // Tracking should only happen if coordinates exist - if (alarm.value.latitude != 0.0 && alarm.value.longitude != 0.0) { + if (activeAlarmId.value != null) { updateCurrentDistance() } else { currentDistance.value = null @@ -134,20 +194,23 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl } @android.annotation.SuppressLint("MissingPermission") - private fun updateCurrentDistance() { + fun updateCurrentDistance() { + val id = activeAlarmId.value + val activeAlarm = savedAlarms.value.find { it.id == id } ?: tempAlarm.value ?: return + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) .addOnSuccessListener { location -> location?.let { val distance = calculateDistance( it.latitude, it.longitude, - alarm.value.latitude, alarm.value.longitude + activeAlarm.latitude, activeAlarm.longitude ) currentDistance.value = distance } } } - private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { + fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { val r = 6371e3 // Earth's radius in meters val phi1 = lat1 * PI / 180 val phi2 = lat2 * PI / 180 @@ -162,18 +225,7 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl return (r * c).toFloat() } - fun updateAlarm(newAlarm: LocationAlarm) { - val oldAlarm = alarm.value - alarm.value = newAlarm - repository.saveAlarm(newAlarm) - - // If coordinates changed, refresh distance immediately - if (oldAlarm.latitude != newAlarm.latitude || oldAlarm.longitude != newAlarm.longitude) { - updateCurrentDistance() - } - } - - fun handleIntent(intent: android.content.Intent): Boolean { + fun handleIntent(intent: Intent): Boolean { val action = intent.action val type = intent.type val data = intent.data @@ -184,15 +236,15 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl ) val textToParse = when { - action == android.content.Intent.ACTION_SEND && type == "text/plain" -> { - intent.getStringExtra(android.content.Intent.EXTRA_TEXT) + action == Intent.ACTION_SEND && type == "text/plain" -> { + intent.getStringExtra(Intent.EXTRA_TEXT) } - action == android.content.Intent.ACTION_VIEW && data?.scheme == "geo" -> { + action == Intent.ACTION_VIEW && data?.scheme == "geo" -> { data.toString() } - action == android.content.Intent.ACTION_VIEW && (data?.host?.contains("google.com") == true || data?.host?.contains( + action == Intent.ACTION_VIEW && (data?.host?.contains("google.com") == true || data?.host?.contains( "goo.gl" ) == true) -> { data.toString() @@ -205,19 +257,16 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl // Check if it's a shortened URL that needs resolution if (textToParse.contains("maps.app.goo.gl") || textToParse.contains("goo.gl/maps")) { + repository.setShowBottomSheet(true) resolveAndParse(textToParse) - return true // Navigate to settings while resolving + return true } return tryParseAndSet(textToParse) } private fun tryParseAndSet(text: String): Boolean { - // Broad regex for coordinates: looks for two floats separated by a comma - // Supports: "40.7127, -74.0059", "geo:40.7127,-74.0059", "@40.7127,-74.0059", "q=40.7127,-74.0059" val commaRegex = Regex("(-?\\d+\\.\\d+)\\s*,\\s*(-?\\d+\\.\\d+)") - - // Pattern for Google Maps data URLs: !3d40.7127!4d-74.0059 val dataRegex = Regex("!3d(-?\\d+\\.\\d+)!4d(-?\\d+\\.\\d+)") val match = commaRegex.find(text) ?: dataRegex.find(text) @@ -228,13 +277,14 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl if (lat != 0.0 && lng != 0.0) { android.util.Log.d("LocationReachedVM", "Parsed coordinates: $lat, $lng") - // Staging mode: don't enable yet - updateAlarm(alarm.value.copy(latitude = lat, longitude = lng, isEnabled = false)) - android.widget.Toast.makeText( - getApplication(), getApplication().getString( - R.string.location_reached_toast_set, lat, lng - ), android.widget.Toast.LENGTH_SHORT - ).show() + repository.setTempAlarm(LocationAlarm( + latitude = lat, + longitude = lng, + name = "New Destination", + isEnabled = false + )) + repository.setShowBottomSheet(true) + updateCurrentDistance() repository.setIsProcessing(false) return true } @@ -263,30 +313,20 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl } android.util.Log.d("LocationReachedVM", "Resolved URL: $resolvedUrl") if (!tryParseAndSet(resolvedUrl)) { - // Additional check for @lat,lng which might not have spaces or exactly match the above val pathRegex = Regex("@(-?\\d+\\.\\d+),(-?\\d+\\.\\d+)") val pathMatch = pathRegex.find(resolvedUrl) if (pathMatch != null) { val lat = pathMatch.groupValues[1].toDoubleOrNull() ?: 0.0 val lng = pathMatch.groupValues[2].toDoubleOrNull() ?: 0.0 if (lat != 0.0 && lng != 0.0) { - // Staging mode: don't enable yet - updateAlarm( - alarm.value.copy( - latitude = lat, - longitude = lng, - isEnabled = false - ) - ) - android.widget.Toast.makeText( - getApplication(), - getApplication().getString( - R.string.location_reached_toast_set, - lat, - lng - ), - android.widget.Toast.LENGTH_SHORT - ).show() + repository.setTempAlarm(LocationAlarm( + latitude = lat, + longitude = lng, + name = "New Destination", + isEnabled = false + )) + repository.setShowBottomSheet(true) + updateCurrentDistance() } } repository.setIsProcessing(false) diff --git a/app/src/main/res/drawable/rounded_history_24.xml b/app/src/main/res/drawable/rounded_history_24.xml new file mode 100644 index 000000000..948b8d34c --- /dev/null +++ b/app/src/main/res/drawable/rounded_history_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be0a67796..830d549f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -672,7 +672,24 @@ Automatically toggle your screen blue light filter based on the foreground app. Enhance security when your device is locked.\n\nRestrict access to some sensitive QS tiles preventing unauthorized network modifications and further preventing them re-attempting to do so by increasing the animation speed to prevent touch spam.\n\nThis feature is not robust and may have flaws such as some tiles which allow toggling directly such as bluetooth or flight mode not being able to be prevented. Secure your apps with a secondary authentication layer.\n\nYour device lock screen authentication method will be used as long as it meets the class 3 biometric security level by Android standards. - Get notified when you get closer to your destination to ensure you never miss the stop.\n\nGo to Google Maps, long press a pin nearby to your destination and make sure it says "Dropped pin" (Otherwise the distance calculation might not be accurate), And then share the location to the Essentials app and start tracking. + Get notified when you get closer to your destination to ensure you never miss the stop.\n\nGo to Google Maps, long press a pin nearby to your destination and make sure it says \"Dropped pin\" (Otherwise the distance calculation might not be accurate), And then share the location to the Essentials app and start tracking. + Add Destination + Edit Destination + Home, Office, etc. + Name + Save + Cancel + Resolving location… + Last Trip + Saved Destinations + No destinations saved yet. + Delete Destination + Tracking Now + Re-Start + Share coordinates (Dropped pin) from Google Maps to Essentials to save as a destination.\n\nThe distance shown is the direct distance to the destination, not the distance along the roads.\n\nTake all calculations of time and distance with a grain of salt as they are not always accurate. + Are we there yet? + Radius: %1$d m + Distance to target: %1$s Freeze apps to stop them from running in the background.\n\nPrevent battery drain and data usage by completely freezing apps when you are not using them. They will be unfrozen instantly when you launch them. The apps will not show up in the app drawer and also will not show up for app updates in Play Store while frozen. A custom input method no-one asked for.\n\nIt is just an experiment. Multiple languages may not get support as it is a very complex and time consuming implementation. Monitor battery levels of all your connected devices.\n\nSee the battery status of your Bluetooth headphones, watch, and other accessories in one place. Connect with AirSync application to display your mac battery level as well. From eb168f43c65bbead55f9bad07a99bca4f9730255 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 22:52:35 +0530 Subject: [PATCH 14/17] feat: add ETA calculation to location reached service --- .../repository/LocationReachedRepository.kt | 17 +++++++ .../essentials/domain/model/LocationAlarm.kt | 1 + .../services/LocationReachedService.kt | 31 +++++++++++-- .../ui/components/cards/LocationAlarmCard.kt | 8 +++- .../configs/LocationReachedSettingsUI.kt | 24 ++++++++++ .../sameerasw/essentials/utils/TimeUtil.kt | 44 +++++++++++++------ .../viewmodels/LocationReachedViewModel.kt | 35 +++++++++++++++ app/src/main/res/values/strings.xml | 11 +++++ 8 files changed, 152 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt index 7cfc385c4..b53d1cf4d 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt @@ -132,4 +132,21 @@ class LocationReachedRepository(context: Context) { fun getStartDistance(): Float { return prefs.getFloat("location_reached_start_dist", 0f) } + + fun saveStartTime(time: Long) { + prefs.edit().putLong("location_reached_start_time", time).apply() + } + + fun getStartTime(): Long { + return prefs.getLong("location_reached_start_time", 0L) + } + + fun updateLastTravelled(alarmId: String, timestamp: Long) { + val alarms = getAlarms().toMutableList() + val index = alarms.indexOfFirst { it.id == alarmId } + if (index != -1) { + alarms[index] = alarms[index].copy(lastTravelled = timestamp) + saveAlarms(alarms) + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt index bd0a37f19..95e1414f8 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt @@ -11,5 +11,6 @@ data class LocationAlarm( @SerializedName("longitude") val longitude: Double = 0.0, @SerializedName("radius") val radius: Int = 1000, // in meters @SerializedName("isEnabled") val isEnabled: Boolean = false, + @SerializedName("lastTravelled") val lastTravelled: Long? = null, @SerializedName("createdAt") val createdAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt index d40504b61..c92bc229e 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt @@ -174,19 +174,39 @@ class LocationReachedService : Service() { private fun updateNotification(distanceKm: Float) { val startDist = repository.getStartDistance() + val startTime = repository.getStartTime() val progressPercent = if (startDist > 0) { ((1.0f - (distanceKm * 1000f / startDist)) * 100).toInt().coerceIn(0, 100) } else 0 - val notification = buildOngoingNotification(distanceKm, progressPercent) + var etaText: String? = null + if (startDist > 0 && startTime > 0) { + val elapsed = System.currentTimeMillis() - startTime + val currentDistMeters = distanceKm * 1000f + val distanceTravelled = startDist - currentDistMeters + if (distanceTravelled > 0 && elapsed > 0) { + val remainingMillis = (currentDistMeters * elapsed / distanceTravelled).toLong() + val remainingMinutes = (remainingMillis / 60000).toInt().coerceAtLeast(1) + + etaText = if (remainingMinutes >= 60) { + val hrs = remainingMinutes / 60 + val mins = remainingMinutes % 60 + getString(R.string.location_reached_eta_hr_min, hrs, mins) + } else { + getString(R.string.location_reached_eta_min, remainingMinutes) + } + } + } + + val notification = buildOngoingNotification(distanceKm, progressPercent, etaText) notificationManager.notify(NOTIFICATION_ID, notification) } private fun buildInitialNotification(): Notification { - return buildOngoingNotification(null, 0) + return buildOngoingNotification(null, 0, null) } - private fun buildOngoingNotification(distanceKm: Float?, progress: Int): Notification { + private fun buildOngoingNotification(distanceKm: Float?, progress: Int, etaText: String?): Notification { val stopIntent = Intent(this, LocationReachedService::class.java).apply { action = ACTION_STOP } @@ -212,8 +232,11 @@ class LocationReachedService : Service() { else getString(R.string.location_reached_dist_km, it) } ?: getString(R.string.location_reached_calculating) - val contentText = + val contentText = if (etaText != null) { + getString(R.string.location_reached_service_remaining_with_eta, distanceText, progress, etaText) + } else { getString(R.string.location_reached_service_remaining, distanceText, progress) + } if (Build.VERSION.SDK_INT >= 35) { val builder = Notification.Builder(this, CHANNEL_ID) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt index f352cee38..bbe642690 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R @@ -43,8 +44,13 @@ fun LocationAlarmCard( ) }, supportingContent = { + val context = androidx.compose.ui.platform.LocalContext.current + val lastTravelledText = alarm.lastTravelled?.let { + stringResource(R.string.location_reached_last_travelled, com.sameerasw.essentials.utils.TimeUtil.formatRelativeDate(it, context)) + } ?: stringResource(R.string.location_reached_never) + Text( - text = "Radius: ${alarm.radius}m", + text = lastTravelledText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt index c60b9f931..16e99c2a3 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt @@ -47,6 +47,7 @@ fun LocationReachedSettingsUI( val startDistance by locationViewModel.startDistance val showBottomSheet by locationViewModel.showBottomSheet val isProcessing by locationViewModel.isProcessingCoordinates + val remainingTimeMinutes by locationViewModel.remainingTimeMinutes val view = androidx.compose.ui.platform.LocalView.current DisposableEffect(locationViewModel) { @@ -78,6 +79,7 @@ fun LocationReachedSettingsUI( activeAlarm = activeAlarm, lastTrip = lastTrip, distance = distance, + remainingTimeMinutes = remainingTimeMinutes, startDistance = startDistance, onStop = { locationViewModel.stopTracking() }, onStart = { @@ -201,6 +203,7 @@ fun TopStatusCard( activeAlarm: com.sameerasw.essentials.domain.model.LocationAlarm?, lastTrip: com.sameerasw.essentials.domain.model.LocationAlarm?, distance: Float?, + remainingTimeMinutes: Int?, startDistance: Float, onStop: () -> Unit, onStart: (String) -> Unit @@ -251,6 +254,27 @@ fun TopStatusCard( fontWeight = FontWeight.Bold ) + remainingTimeMinutes?.let { mins -> + val etaText = if (mins >= 60) { + stringResource(R.string.location_reached_eta_hr_min, mins / 60, mins % 60) + } else { + stringResource(R.string.location_reached_eta_min, mins) + } + + Text( + text = etaText, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.location_reached_to_go).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold + ) + } + if (distance != null && startDistance > 0) { val progress = (1.0f - (distance / startDistance)).coerceIn(0.0f, 1.0f) Spacer(modifier = Modifier.height(24.dp)) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/TimeUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/TimeUtil.kt index efe3a8987..316246090 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/TimeUtil.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/TimeUtil.kt @@ -12,23 +12,39 @@ object TimeUtil { val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) inputFormat.timeZone = TimeZone.getTimeZone("UTC") val date = inputFormat.parse(githubDate) ?: return githubDate - val now = System.currentTimeMillis() - val diff = now - date.time + formatRelativeDate(date.time, context) + } catch (e: Exception) { + githubDate + } + } - when { - diff < 60000 -> context.getString(R.string.time_just_now) - diff < 3600000 -> context.getString(R.string.time_min_ago, diff / 60000) - diff < 86400000 -> context.getString(R.string.time_hour_ago, diff / 3600000) - diff < 2592000000L -> context.getString(R.string.time_day_ago, diff / 86400000) - diff < 31536000000L -> context.getString( - R.string.time_month_ago, - diff / 2592000000L - ) + fun formatRelativeDate(timestamp: Long, context: Context): String { + val now = System.currentTimeMillis() + val diff = now - timestamp - else -> context.getString(R.string.time_year_ago, diff / 31536000000L) + val days = diff / 86400000L + return when { + diff < 60000L -> context.getString(R.string.time_just_now) + diff < 3600000L -> context.getString(R.string.time_min_ago, (diff / 60000).toInt()) + diff < 86400000L && isSameDay(now, timestamp) -> { + if (diff < 3600000L * 24) { + context.getString(R.string.time_hour_ago, (diff / 3600000L).toInt()) + } else { + context.getString(R.string.today) + } } - } catch (e: Exception) { - githubDate + days == 1L || (days == 0L && !isSameDay(now, timestamp)) -> context.getString(R.string.yesterday) + days < 7L -> context.getString(R.string.time_days_ago, days.toInt()) + days < 30L -> context.getString(R.string.time_weeks_ago, (days / 7).toInt()) + days < 365L -> context.getString(R.string.time_months_ago, (days / 30).toInt()) + else -> context.getString(R.string.time_year_ago, (days / 365).toInt()) } } + + private fun isSameDay(t1: Long, t2: Long): Boolean { + val cal1 = java.util.Calendar.getInstance().apply { timeInMillis = t1 } + val cal2 = java.util.Calendar.getInstance().apply { timeInMillis = t2 } + return cal1.get(java.util.Calendar.YEAR) == cal2.get(java.util.Calendar.YEAR) && + cal1.get(java.util.Calendar.DAY_OF_YEAR) == cal2.get(java.util.Calendar.DAY_OF_YEAR) + } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt index 05cda480c..6489902f0 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt @@ -53,6 +53,12 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl var startDistance = mutableStateOf(repository.getStartDistance()) private set + var remainingTimeMinutes = mutableStateOf(null) + private set + + var startTime = mutableStateOf(repository.getStartTime()) + private set + init { // Observe shared state for real-time updates across activities viewModelScope.launch { @@ -131,6 +137,11 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl repository.saveActiveAlarmId(alarmId) LocationReachedService.start(getApplication()) + val now = System.currentTimeMillis() + repository.saveStartTime(now) + startTime.value = now + repository.updateLastTravelled(alarmId, now) + // Refreshed start distance logic fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) .addOnSuccessListener { location -> @@ -162,8 +173,11 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl repository.saveActiveAlarmId(null) LocationReachedService.stop(getApplication()) currentDistance.value = null + remainingTimeMinutes.value = null startDistance.value = 0f repository.saveStartDistance(0f) + repository.saveStartTime(0L) + startTime.value = 0L } private var distanceTrackingJob: kotlinx.coroutines.Job? = null @@ -206,10 +220,31 @@ class LocationReachedViewModel(application: Application) : AndroidViewModel(appl activeAlarm.latitude, activeAlarm.longitude ) currentDistance.value = distance + calculateEta(distance) } } } + private fun calculateEta(currentDistMeters: Float) { + val startDistMeters = startDistance.value + val startT = startTime.value + if (startDistMeters <= 0 || startT <= 0L) { + remainingTimeMinutes.value = null + return + } + + val elapsedMillis = System.currentTimeMillis() - startT + val distanceTravelled = startDistMeters - currentDistMeters + + if (distanceTravelled <= 0 || elapsedMillis <= 0) { + remainingTimeMinutes.value = null + return + } + + val remainingMillis = (currentDistMeters * elapsedMillis / distanceTravelled).toLong() + remainingTimeMinutes.value = (remainingMillis / 60000).toInt().coerceAtLeast(1) + } + fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float { val r = 6371e3 // Earth's radius in meters val phi1 = lat1 * PI / 180 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 830d549f8..09f762b01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -690,6 +690,12 @@ Are we there yet? Radius: %1$d m Distance to target: %1$s + Last: %1$s + Never + To go + %1$d min + %1$d hr %2$d min + %1$s (%2$d%%) • %3$s to go Freeze apps to stop them from running in the background.\n\nPrevent battery drain and data usage by completely freezing apps when you are not using them. They will be unfrozen instantly when you launch them. The apps will not show up in the app drawer and also will not show up for app updates in Play Store while frozen. A custom input method no-one asked for.\n\nIt is just an experiment. Multiple languages may not get support as it is a very complex and time consuming implementation. Monitor battery levels of all your connected devices.\n\nSee the battery status of your Bluetooth headphones, watch, and other accessories in one place. Connect with AirSync application to display your mac battery level as well. @@ -1166,10 +1172,15 @@ just now + Today + Yesterday %1$dm ago %1$dh ago %1$dd ago + %1$d days ago + %1$d weeks ago %1$dmo ago + %1$d months ago %1$dy ago Retry From fff0c048ad447af9328b7b015e1441452532bd23 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 23:15:30 +0530 Subject: [PATCH 15/17] feat: add icon picker and dynamic notification icons for location alarms --- .../essentials/domain/model/LocationAlarm.kt | 1 + .../services/LocationReachedService.kt | 20 +++- .../ui/components/LocationIconPicker.kt | 94 +++++++++++++++++++ .../ui/components/cards/LocationAlarmCard.kt | 10 ++ .../sheets/LocationReachedBottomSheet.kt | 8 ++ .../configs/LocationReachedSettingsUI.kt | 26 ++++- .../main/res/drawable/round_navigation_24.xml | 5 + .../main/res/drawable/round_play_arrow_24.xml | 5 + .../drawable/rounded_account_balance_24.xml | 5 + .../res/drawable/rounded_apartment_24.xml | 5 + .../res/drawable/rounded_beach_access_24.xml | 5 + .../drawable/rounded_directions_boat_24.xml | 5 + .../drawable/rounded_directions_bus_24.xml | 5 + .../main/res/drawable/rounded_favorite_24.xml | 2 +- .../res/drawable/rounded_fork_spoon_24.xml | 5 + .../res/drawable/rounded_garage_home_24.xml | 5 + app/src/main/res/drawable/rounded_home_24.xml | 5 + .../res/drawable/rounded_local_pizza_24.xml | 5 + .../main/res/drawable/rounded_school_24.xml | 5 + .../res/drawable/rounded_shopping_cart_24.xml | 5 + .../res/drawable/rounded_storefront_24.xml | 5 + .../main/res/drawable/rounded_train_24.xml | 5 + app/src/main/res/drawable/rounded_work_24.xml | 5 + 23 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/LocationIconPicker.kt create mode 100644 app/src/main/res/drawable/round_navigation_24.xml create mode 100644 app/src/main/res/drawable/round_play_arrow_24.xml create mode 100644 app/src/main/res/drawable/rounded_account_balance_24.xml create mode 100644 app/src/main/res/drawable/rounded_apartment_24.xml create mode 100644 app/src/main/res/drawable/rounded_beach_access_24.xml create mode 100644 app/src/main/res/drawable/rounded_directions_boat_24.xml create mode 100644 app/src/main/res/drawable/rounded_directions_bus_24.xml create mode 100644 app/src/main/res/drawable/rounded_fork_spoon_24.xml create mode 100644 app/src/main/res/drawable/rounded_garage_home_24.xml create mode 100644 app/src/main/res/drawable/rounded_home_24.xml create mode 100644 app/src/main/res/drawable/rounded_local_pizza_24.xml create mode 100644 app/src/main/res/drawable/rounded_school_24.xml create mode 100644 app/src/main/res/drawable/rounded_shopping_cart_24.xml create mode 100644 app/src/main/res/drawable/rounded_storefront_24.xml create mode 100644 app/src/main/res/drawable/rounded_train_24.xml create mode 100644 app/src/main/res/drawable/rounded_work_24.xml diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt index 95e1414f8..bfe149fc1 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt @@ -12,5 +12,6 @@ data class LocationAlarm( @SerializedName("radius") val radius: Int = 1000, // in meters @SerializedName("isEnabled") val isEnabled: Boolean = false, @SerializedName("lastTravelled") val lastTravelled: Long? = null, + @SerializedName("iconResName") val iconResName: String = "round_navigation_24", @SerializedName("createdAt") val createdAt: Long = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt index c92bc229e..e0df7a315 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt @@ -239,8 +239,14 @@ class LocationReachedService : Service() { } if (Build.VERSION.SDK_INT >= 35) { + val activeId = repository.getActiveAlarmId() + val alarm = repository.getAlarms().find { it.id == activeId } + val iconResName = alarm?.iconResName ?: "round_navigation_24" + val iconResId = resources.getIdentifier(iconResName, "drawable", packageName) + val finalIconId = if (iconResId != 0) iconResId else R.drawable.round_navigation_24 + val builder = Notification.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.rounded_navigation_24) + .setSmallIcon(finalIconId) .setContentTitle(getString(R.string.location_reached_service_title)) .setContentText(contentText) .setOngoing(true) @@ -262,8 +268,8 @@ class LocationReachedService : Service() { .setProgressTrackerIcon( Icon.createWithResource( this, - R.drawable.rounded_navigation_24 - ) + R.drawable.round_play_arrow_24 + ).setTint(getColor(android.R.color.system_accent1_300)) ) builder.style = progressStyle } catch (_: Throwable) { @@ -296,8 +302,14 @@ class LocationReachedService : Service() { return builder.build() } + val activeId = repository.getActiveAlarmId() + val alarm = repository.getAlarms().find { it.id == activeId } + val iconResName = alarm?.iconResName ?: "round_navigation_24" + val iconResId = resources.getIdentifier(iconResName, "drawable", packageName) + val finalIconId = if (iconResId != 0) iconResId else R.drawable.round_navigation_24 + val builder = NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.rounded_navigation_24) + .setSmallIcon(finalIconId) .setContentTitle(getString(R.string.location_reached_service_title)) .setContentText(contentText) .setOngoing(true) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/LocationIconPicker.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/LocationIconPicker.kt new file mode 100644 index 000000000..7e4c7183e --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/LocationIconPicker.kt @@ -0,0 +1,94 @@ +package com.sameerasw.essentials.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LocationIconPicker( + selectedIconName: String, + onIconSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + val icons = listOf( + "round_navigation_24", + "rounded_home_24", + "rounded_work_24", + "rounded_apartment_24", + "rounded_shopping_cart_24", + "rounded_school_24", + "rounded_storefront_24", + "rounded_fork_spoon_24", + "rounded_favorite_24", + "rounded_account_balance_24", + "rounded_garage_home_24", + "rounded_beach_access_24", + "rounded_local_pizza_24", + "rounded_train_24", + "rounded_directions_bus_24", + "rounded_flight_24", + "rounded_directions_boat_24" + ) + + val carouselState = rememberCarouselState { icons.size } + val context = LocalContext.current + val view = androidx.compose.ui.platform.LocalView.current + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Pick an icon", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + HorizontalMultiBrowseCarousel( + state = carouselState, + preferredItemWidth = 64.dp, + itemSpacing = 4.dp, + contentPadding = PaddingValues(horizontal = 0.dp), + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + ) { index -> + val iconName = icons[index] + val isSelected = iconName == selectedIconName + val iconResId = context.resources.getIdentifier(iconName, "drawable", context.packageName) + + Box( + modifier = Modifier + .fillMaxSize() + .maskClip(MaterialTheme.shapes.medium) + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.background + ) + .clickable { + HapticUtil.performVirtualKeyHaptic(view) + onIconSelected(iconName) + }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.round_navigation_24), + contentDescription = null, + tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt index bbe642690..1094169ff 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/LocationAlarmCard.kt @@ -35,6 +35,16 @@ fun LocationAlarmCard( com.sameerasw.essentials.utils.HapticUtil.performVirtualKeyHaptic(view) onClick() }, + leadingContent = { + val context = androidx.compose.ui.platform.LocalContext.current + val iconResId = context.resources.getIdentifier(alarm.iconResName, "drawable", context.packageName) + Icon( + painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.round_navigation_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + }, headlineContent = { Text( text = alarm.name.ifEmpty { "Destination" }, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt index 6ca3f7d91..cbb4feab0 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/LocationReachedBottomSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.model.LocationAlarm +import com.sameerasw.essentials.ui.components.LocationIconPicker import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import com.sameerasw.essentials.viewmodels.LocationReachedViewModel @@ -89,6 +90,13 @@ fun LocationReachedBottomSheet( shape = MaterialTheme.shapes.large ) + LocationIconPicker( + selectedIconName = currentAlarm.iconResName, + onIconSelected = { + viewModel.setTempAlarm(currentAlarm.copy(iconResName = it)) + } + ) + // Coordinates Display Column { Text( diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt index 16e99c2a3..fc5d83680 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt @@ -159,7 +159,7 @@ fun LocationReachedSettingsUI( textAlign = androidx.compose.ui.text.style.TextAlign.Start, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 32.dp, vertical = 8.dp) + .padding(horizontal = 16.dp, vertical = 8.dp) ) } @@ -224,6 +224,17 @@ fun TopStatusCard( horizontalAlignment = Alignment.CenterHorizontally ) { if (isTracking) { + val context = LocalContext.current + val iconResId = context.resources.getIdentifier(displayAlarm?.iconResName ?: "round_navigation_24", "drawable", context.packageName) + + Icon( + painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.round_navigation_24), + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( text = stringResource(R.string.location_reached_tracking_now), style = MaterialTheme.typography.labelLarge, @@ -233,8 +244,9 @@ fun TopStatusCard( Spacer(modifier = Modifier.height(4.dp)) Text( text = displayAlarm?.name?.ifEmpty { "Destination" } ?: "Destination", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(24.dp)) @@ -308,9 +320,13 @@ fun TopStatusCard( Text(stringResource(R.string.action_stop)) } } else if (lastTrip != null) { + val context = LocalContext.current + val iconResId = context.resources.getIdentifier(lastTrip.iconResName, "drawable", context.packageName) + Icon( - painter = painterResource(R.drawable.rounded_history_24), + painter = painterResource(id = if (iconResId != 0) iconResId else R.drawable.round_navigation_24), contentDescription = null, + modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) Spacer(modifier = Modifier.height(8.dp)) @@ -338,7 +354,7 @@ fun TopStatusCard( } else { // Completely empty state for top card Icon( - painter = painterResource(R.drawable.rounded_navigation_24), + painter = painterResource(R.drawable.round_navigation_24), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) diff --git a/app/src/main/res/drawable/round_navigation_24.xml b/app/src/main/res/drawable/round_navigation_24.xml new file mode 100644 index 000000000..52778d649 --- /dev/null +++ b/app/src/main/res/drawable/round_navigation_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/round_play_arrow_24.xml b/app/src/main/res/drawable/round_play_arrow_24.xml new file mode 100644 index 000000000..42c14a76c --- /dev/null +++ b/app/src/main/res/drawable/round_play_arrow_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_account_balance_24.xml b/app/src/main/res/drawable/rounded_account_balance_24.xml new file mode 100644 index 000000000..0dc270577 --- /dev/null +++ b/app/src/main/res/drawable/rounded_account_balance_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_apartment_24.xml b/app/src/main/res/drawable/rounded_apartment_24.xml new file mode 100644 index 000000000..a9bb974bf --- /dev/null +++ b/app/src/main/res/drawable/rounded_apartment_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_beach_access_24.xml b/app/src/main/res/drawable/rounded_beach_access_24.xml new file mode 100644 index 000000000..cd3c2e5b8 --- /dev/null +++ b/app/src/main/res/drawable/rounded_beach_access_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_directions_boat_24.xml b/app/src/main/res/drawable/rounded_directions_boat_24.xml new file mode 100644 index 000000000..a0057825e --- /dev/null +++ b/app/src/main/res/drawable/rounded_directions_boat_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_directions_bus_24.xml b/app/src/main/res/drawable/rounded_directions_bus_24.xml new file mode 100644 index 000000000..e58032b7b --- /dev/null +++ b/app/src/main/res/drawable/rounded_directions_bus_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_favorite_24.xml b/app/src/main/res/drawable/rounded_favorite_24.xml index a73733a03..a9e9a09bf 100644 --- a/app/src/main/res/drawable/rounded_favorite_24.xml +++ b/app/src/main/res/drawable/rounded_favorite_24.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/rounded_fork_spoon_24.xml b/app/src/main/res/drawable/rounded_fork_spoon_24.xml new file mode 100644 index 000000000..bc43432cd --- /dev/null +++ b/app/src/main/res/drawable/rounded_fork_spoon_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_garage_home_24.xml b/app/src/main/res/drawable/rounded_garage_home_24.xml new file mode 100644 index 000000000..82ab52e7f --- /dev/null +++ b/app/src/main/res/drawable/rounded_garage_home_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_home_24.xml b/app/src/main/res/drawable/rounded_home_24.xml new file mode 100644 index 000000000..d971995e4 --- /dev/null +++ b/app/src/main/res/drawable/rounded_home_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_local_pizza_24.xml b/app/src/main/res/drawable/rounded_local_pizza_24.xml new file mode 100644 index 000000000..7c555d8a8 --- /dev/null +++ b/app/src/main/res/drawable/rounded_local_pizza_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_school_24.xml b/app/src/main/res/drawable/rounded_school_24.xml new file mode 100644 index 000000000..fdf7a026f --- /dev/null +++ b/app/src/main/res/drawable/rounded_school_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_shopping_cart_24.xml b/app/src/main/res/drawable/rounded_shopping_cart_24.xml new file mode 100644 index 000000000..fffc5b47f --- /dev/null +++ b/app/src/main/res/drawable/rounded_shopping_cart_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_storefront_24.xml b/app/src/main/res/drawable/rounded_storefront_24.xml new file mode 100644 index 000000000..545e73bde --- /dev/null +++ b/app/src/main/res/drawable/rounded_storefront_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_train_24.xml b/app/src/main/res/drawable/rounded_train_24.xml new file mode 100644 index 000000000..442a455f4 --- /dev/null +++ b/app/src/main/res/drawable/rounded_train_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_work_24.xml b/app/src/main/res/drawable/rounded_work_24.xml new file mode 100644 index 000000000..66c3b8a69 --- /dev/null +++ b/app/src/main/res/drawable/rounded_work_24.xml @@ -0,0 +1,5 @@ + + + + + From d8fd31413418f066767cfcfb8ea8fe11218349e5 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 23:35:25 +0530 Subject: [PATCH 16/17] fix: build optimization fixes for new are we there yet changes --- app/proguard-rules.pro | 11 +++++++++- .../repository/LocationReachedRepository.kt | 1 + .../viewmodels/LocationReachedViewModel.kt | 1 + app/src/main/res/raw/keep.xml | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/raw/keep.xml diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 18ac13ce5..cb35dfa8b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -41,4 +41,13 @@ # Emoji data classes for Gson -keep class com.sameerasw.essentials.ui.ime.EmojiObject { *; } -keep class com.sameerasw.essentials.ui.ime.EmojiCategory { *; } --keep class com.sameerasw.essentials.ui.ime.EmojiDataResponse { *; } \ No newline at end of file +-keep class com.sameerasw.essentials.ui.ime.EmojiDataResponse { *; } +# Keep ViewModel constructors for reflection-based instantiation +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + public (...); +} + +# Ensure anonymous TypeToken subclasses (used for GSON generic lists) are kept +-keepclassmembers class * extends com.google.gson.reflect.TypeToken { + protected (...); +} diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt index b53d1cf4d..69368108f 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID +@androidx.annotation.Keep class LocationReachedRepository(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt index 6489902f0..c261fc2b2 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt @@ -25,6 +25,7 @@ import kotlin.math.pow import kotlin.math.sin import kotlin.math.sqrt +@androidx.annotation.Keep class LocationReachedViewModel(application: Application) : AndroidViewModel(application) { private val repository = LocationReachedRepository(application) private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application) diff --git a/app/src/main/res/raw/keep.xml b/app/src/main/res/raw/keep.xml new file mode 100644 index 000000000..7b638acc7 --- /dev/null +++ b/app/src/main/res/raw/keep.xml @@ -0,0 +1,20 @@ + + From 298e1da24161e6e4410a06ac9db4274dab4274c3 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 31 Mar 2026 23:36:21 +0530 Subject: [PATCH 17/17] version: Updated to v12.5 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 73e86271e..1517abe6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.sameerasw.essentials" minSdk = 26 targetSdk = 36 - versionCode = 35 - versionName = "12.4" + versionCode = 36 + versionName = "12.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }