diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f619511d..0a150fc6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "com.sameerasw.essentials"
minSdk = 24
targetSdk = 36
- versionCode = 17
- versionName = "8.3"
+ versionCode = 18
+ versionName = "8.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -69,9 +69,9 @@ dependencies {
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.ui)
implementation(libs.androidx.compose.foundation)
+ implementation(libs.material)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
@@ -94,4 +94,7 @@ dependencies {
// Volume Long Press
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
implementation("dev.rikka.shizuku:api:13.1.5")
+
+ // Google Maps & Location
+ implementation(libs.play.services.location)
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d7cb0ce8..72bf52e3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,14 +5,17 @@
-
+
+
+
+
@@ -22,6 +25,9 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -62,6 +81,18 @@
android:theme="@style/Theme.Essentials">
+
+
+
+
!isAccessibilityEnabled || !isWriteSecureSettingsEnabled || !viewModel.isDeviceAdminEnabled.value
"App lock" -> !isAccessibilityEnabled
"Freeze" -> !viewModel.isShizukuAvailable.value || !viewModel.isShizukuPermissionGranted.value
+ "Location reached" -> !viewModel.isLocationPermissionGranted.value || !viewModel.isBackgroundLocationPermissionGranted.value
else -> false
}
showPermissionSheet = hasMissingPermissions
@@ -385,6 +387,26 @@ class FeatureSettingsActivity : FragmentActivity() {
isGranted = viewModel.isShizukuPermissionGranted.value
)
)
+ "Location reached" -> listOf(
+ PermissionItem(
+ iconRes = R.drawable.rounded_navigation_24,
+ title = R.string.perm_location_title,
+ description = R.string.perm_location_desc,
+ dependentFeatures = PermissionRegistry.getFeatures("LOCATION"),
+ actionLabel = R.string.perm_action_grant,
+ action = { viewModel.requestLocationPermission(this) },
+ isGranted = viewModel.isLocationPermissionGranted.value
+ ),
+ PermissionItem(
+ iconRes = R.drawable.rounded_navigation_24,
+ title = R.string.perm_bg_location_title,
+ description = R.string.perm_bg_location_desc,
+ dependentFeatures = PermissionRegistry.getFeatures("BACKGROUND_LOCATION"),
+ actionLabel = R.string.perm_action_grant,
+ action = { viewModel.requestBackgroundLocationPermission(this) },
+ isGranted = viewModel.isBackgroundLocationPermissionGranted.value
+ )
+ )
else -> emptyList()
}
@@ -409,7 +431,8 @@ class FeatureSettingsActivity : FragmentActivity() {
hasSearch = false,
onBackClick = { finish() },
scrollBehavior = scrollBehavior,
- subtitle = if (featureObj != null) stringResource(featureObj.description) else ""
+ subtitle = if (featureObj != null) stringResource(featureObj.description) else "",
+ isBeta = featureObj?.isBeta ?: false
)
},
floatingActionButton = {
@@ -520,6 +543,13 @@ class FeatureSettingsActivity : FragmentActivity() {
highlightSetting = highlightSetting
)
}
+ "Location reached" -> {
+ LocationReachedSettingsUI(
+ mainViewModel = viewModel,
+ modifier = Modifier.padding(top = 16.dp),
+ highlightSetting = highlightSetting
+ )
+ }
// else -> default UI (optional cleanup)
}
}
diff --git a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt
index ace7c5be..6a993e2f 100644
--- a/app/src/main/java/com/sameerasw/essentials/MainActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/MainActivity.kt
@@ -43,6 +43,7 @@ import com.sameerasw.essentials.ui.composables.ComingSoonDIYScreen
import com.sameerasw.essentials.ui.theme.EssentialsTheme
import com.sameerasw.essentials.utils.HapticUtil
import com.sameerasw.essentials.viewmodels.MainViewModel
+import com.sameerasw.essentials.viewmodels.LocationReachedViewModel
import com.sameerasw.essentials.ui.components.sheets.UpdateBottomSheet
import com.sameerasw.essentials.ui.components.sheets.InstructionsBottomSheet
import com.sameerasw.essentials.ui.composables.configs.FreezeSettingsUI
@@ -56,6 +57,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
class MainActivity : FragmentActivity() {
val viewModel: MainViewModel by viewModels()
+ val locationViewModel: LocationReachedViewModel by viewModels()
private var isAppReady = false
override fun onCreate(savedInstanceState: Bundle?) {
@@ -78,7 +80,7 @@ class MainActivity : FragmentActivity() {
splashScreen.setOnExitAnimationListener { splashScreenViewProvider ->
try {
val splashScreenView = splashScreenViewProvider.view
- val splashIcon = splashScreenViewProvider.iconView
+ val splashIcon = try { splashScreenViewProvider.iconView } catch (e: Exception) { null }
// Animate the splash screen view fade out
val fadeOut = ObjectAnimator.ofFloat(splashScreenView, "alpha", 1f, 0f).apply {
@@ -139,6 +141,7 @@ class MainActivity : FragmentActivity() {
}
Log.d("MainActivity", "onCreate with action: ${intent?.action}")
+ handleLocationIntent(intent)
// Initialize HapticUtil with saved preferences
HapticUtil.initialize(this)
@@ -301,6 +304,19 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
+ setIntent(intent)
Log.d("MainActivity", "onNewIntent with action: ${intent.action}")
+ handleLocationIntent(intent)
+ }
+
+ private fun handleLocationIntent(intent: Intent?) {
+ intent?.let {
+ if (locationViewModel.handleIntent(it)) {
+ val settingsIntent = Intent(this, FeatureSettingsActivity::class.java).apply {
+ putExtra("feature", "Location reached")
+ }
+ startActivity(settingsIntent)
+ }
+ }
}
}
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
new file mode 100644
index 00000000..170837ff
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/data/repository/LocationReachedRepository.kt
@@ -0,0 +1,57 @@
+package com.sameerasw.essentials.data.repository
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.sameerasw.essentials.domain.model.LocationAlarm
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class LocationReachedRepository(context: Context) {
+ private val prefs: SharedPreferences = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE)
+
+ companion object {
+ private val _isProcessing = MutableStateFlow(false)
+ val isProcessing = _isProcessing.asStateFlow()
+
+ private val _alarmFlow = MutableStateFlow(null)
+ val alarmFlow = _alarmFlow.asStateFlow()
+ }
+
+ init {
+ if (_alarmFlow.value == null) {
+ _alarmFlow.value = getAlarm()
+ }
+ }
+
+ fun setIsProcessing(processing: Boolean) {
+ _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()
+ }
+ _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 saveStartDistance(distance: Float) {
+ prefs.edit().putFloat("location_reached_start_dist", distance).apply()
+ }
+
+ fun getStartDistance(): Float {
+ return prefs.getFloat("location_reached_start_dist", 0f)
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt
index 4f399426..ae0c3060 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/Feature.kt
@@ -30,7 +30,8 @@ abstract class Feature(
val permissionKeys: List = emptyList(),
val searchableSettings: List = emptyList(),
val showToggle: Boolean = true,
- val hasMoreSettings: Boolean = true
+ val hasMoreSettings: Boolean = true,
+ val isBeta: Boolean = false
) {
abstract fun isEnabled(viewModel: MainViewModel): Boolean
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
new file mode 100644
index 00000000..90e8bbf6
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/LocationAlarm.kt
@@ -0,0 +1,8 @@
+package com.sameerasw.essentials.domain.model
+
+data class LocationAlarm(
+ val latitude: Double = 0.0,
+ val longitude: Double = 0.0,
+ val radius: Int = 1000, // in meters
+ val isEnabled: Boolean = false
+)
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/SearchableItem.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/SearchableItem.kt
index 2ab9c05a..8fa2d701 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/model/SearchableItem.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/model/SearchableItem.kt
@@ -12,5 +12,6 @@ data class SearchableItem(
val parentFeature: String? = null,
val targetSettingHighlightKey: String? = null,
val titleRes: Int? = null,
- val descriptionRes: Int? = null
+ val descriptionRes: Int? = null,
+ val isBeta: Boolean = false
)
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
index 87454ca7..306bcaf3 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
@@ -414,6 +414,20 @@ object FeatureRegistry {
override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = viewModel.setAppLockEnabled(enabled, context)
},
+ object : Feature(
+ id = "Location reached",
+ title = R.string.feat_location_reached_title,
+ iconRes = R.drawable.rounded_navigation_24,
+ category = R.string.cat_tools,
+ description = R.string.feat_location_reached_desc,
+ permissionKeys = listOf("LOCATION", "BACKGROUND_LOCATION", "USE_FULL_SCREEN_INTENT"),
+ showToggle = false,
+ isBeta = true
+ ) {
+ override fun isEnabled(viewModel: MainViewModel) = true
+ override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) {}
+ },
+
object : Feature(
id = "Freeze",
title = R.string.feat_freeze_title,
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
index d7a2ed08..8dbf3ca4 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt
@@ -47,4 +47,9 @@ fun initPermissionRegistry() {
// Device Admin permission
PermissionRegistry.register("DEVICE_ADMIN", R.string.feat_screen_locked_security_title)
+
+ // Location permission
+ PermissionRegistry.register("LOCATION", R.string.feat_location_reached_title)
+ PermissionRegistry.register("BACKGROUND_LOCATION", R.string.feat_location_reached_title)
+ PermissionRegistry.register("USE_FULL_SCREEN_INTENT", R.string.feat_location_reached_title)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/SearchRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/SearchRegistry.kt
index 4f4d9157..a1255ee7 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/registry/SearchRegistry.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/SearchRegistry.kt
@@ -26,7 +26,8 @@ object SearchRegistry {
category = featureCategory,
icon = feature.iconRes,
featureKey = feature.id,
- keywords = listOf(context.getString(R.string.keyword_feature), context.getString(R.string.keyword_settings))
+ keywords = listOf(context.getString(R.string.keyword_feature), context.getString(R.string.keyword_settings)),
+ isBeta = feature.isBeta
)
)
@@ -41,7 +42,8 @@ object SearchRegistry {
featureKey = feature.id,
parentFeature = featureTitle,
targetSettingHighlightKey = setting.targetSettingHighlightKey,
- keywords = if (setting.keywordRes != 0) context.resources.getStringArray(setting.keywordRes).toList() else emptyList()
+ keywords = if (setting.keywordRes != 0) context.resources.getStringArray(setting.keywordRes).toList() else emptyList(),
+ isBeta = feature.isBeta
)
)
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt
new file mode 100644
index 00000000..379f376b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt
@@ -0,0 +1,275 @@
+package com.sameerasw.essentials.services
+
+import android.app.*
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.data.repository.LocationReachedRepository
+import com.sameerasw.essentials.MainActivity
+import kotlinx.coroutines.*
+import kotlin.math.*
+
+class LocationReachedService : Service() {
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private var trackingJob: Job? = null
+ private var isAlarmTriggered = false
+
+ private val repository by lazy { LocationReachedRepository(this) }
+ private val fusedLocationClient by lazy { LocationServices.getFusedLocationProviderClient(this) }
+ private val notificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
+
+ companion object {
+ private const val NOTIFICATION_ID = 2001
+ private const val ALARM_NOTIFICATION_ID = 1001
+ private const val CHANNEL_ID = "location_reached_live"
+ private const val ACTION_STOP = "com.sameerasw.essentials.STOP_LOCATION_REACHED"
+
+ fun start(context: Context) {
+ val intent = Intent(context, LocationReachedService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, LocationReachedService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent?.action == ACTION_STOP) {
+ stopTracking()
+ return START_NOT_STICKY
+ }
+
+ isAlarmTriggered = false
+ createNotificationChannel()
+ startForeground(NOTIFICATION_ID, buildInitialNotification())
+ startTracking()
+
+ return START_STICKY
+ }
+
+ private fun startTracking() {
+ trackingJob?.cancel()
+ trackingJob = serviceScope.launch {
+ while (isActive) {
+ val alarm = repository.getAlarm()
+ if (alarm.isEnabled && alarm.latitude != 0.0 && alarm.longitude != 0.0) {
+ updateProgress(alarm)
+ } else {
+ stopSelf()
+ break
+ }
+ delay(10000) // Update every 10 seconds for better responsiveness
+ }
+ }
+ }
+
+ private fun stopTracking() {
+ val alarm = repository.getAlarm()
+ repository.saveAlarm(alarm.copy(isEnabled = false))
+ stopSelf()
+ }
+
+ @android.annotation.SuppressLint("MissingPermission")
+ private fun updateProgress(alarm: com.sameerasw.essentials.domain.model.LocationAlarm) {
+ fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null)
+ .addOnSuccessListener { location ->
+ location?.let {
+ val distance = calculateDistance(it.latitude, it.longitude, alarm.latitude, alarm.longitude)
+ val distanceKm = distance / 1000f
+
+ // Watchdog: If we reached the radius but geofence didn't trigger
+ if (distance <= alarm.radius && !isAlarmTriggered) {
+ isAlarmTriggered = true
+ triggerArrivalAlarm()
+ }
+
+ updateNotification(distanceKm)
+ }
+ }
+ }
+
+ private fun triggerArrivalAlarm() {
+ val channelId = "location_reached_channel"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ channelId,
+ getString(R.string.feat_location_reached_title),
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ enableLights(true)
+ enableVibration(true)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val fullScreenIntent = Intent(this, com.sameerasw.essentials.ui.activities.LocationAlarmActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION
+ }
+ val fullScreenPendingIntent = PendingIntent.getActivity(
+ this, 0, fullScreenIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(this, channelId)
+ .setSmallIcon(R.drawable.rounded_navigation_24)
+ .setContentTitle(getString(R.string.location_reached_notification_title))
+ .setContentText(getString(R.string.location_reached_notification_desc))
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setDefaults(NotificationCompat.DEFAULT_ALL)
+ .setFullScreenIntent(fullScreenPendingIntent, true)
+ .setAutoCancel(true)
+ .build()
+
+ notificationManager.notify(ALARM_NOTIFICATION_ID, notification)
+ }
+
+ private fun updateNotification(distanceKm: Float) {
+ val startDist = repository.getStartDistance()
+ val progressPercent = if (startDist > 0) {
+ ((1.0f - (distanceKm * 1000f / startDist)) * 100).toInt().coerceIn(0, 100)
+ } else 0
+
+ val notification = buildOngoingNotification(distanceKm, progressPercent)
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ }
+
+ private fun buildInitialNotification(): Notification {
+ return buildOngoingNotification(null, 0)
+ }
+
+ private fun buildOngoingNotification(distanceKm: Float?, progress: Int): Notification {
+ val stopIntent = Intent(this, LocationReachedService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ val mainIntent = Intent(this, MainActivity::class.java).apply {
+ putExtra("feature", "Location reached")
+ }
+ val mainPendingIntent = PendingIntent.getActivity(this, 0, mainIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ val distanceText = distanceKm?.let {
+ if (it < 1.0) getString(R.string.location_reached_dist_m, (it * 1000).toInt())
+ else getString(R.string.location_reached_dist_km, it)
+ } ?: getString(R.string.location_reached_calculating)
+
+ val contentText = getString(R.string.location_reached_service_remaining, distanceText, progress)
+
+ if (Build.VERSION.SDK_INT >= 35) {
+ val builder = Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.rounded_navigation_24)
+ .setContentTitle(getString(R.string.location_reached_service_title))
+ .setContentText(contentText)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setContentIntent(mainPendingIntent)
+ .addAction(Notification.Action.Builder(
+ Icon.createWithResource(this, R.drawable.rounded_power_settings_new_24),
+ getString(R.string.location_reached_stop_tracking), stopPendingIntent).build())
+
+ if (Build.VERSION.SDK_INT >= 36) {
+ try {
+ val progressStyle = Notification.ProgressStyle()
+ .setStyledByProgress(true)
+ .setProgress(progress)
+ .setProgressTrackerIcon(Icon.createWithResource(this, R.drawable.rounded_navigation_24))
+ builder.setStyle(progressStyle)
+ } catch (_: Throwable) {
+ builder.setProgress(100, progress, false)
+ }
+ } else {
+ builder.setProgress(100, progress, false)
+ }
+
+ try {
+ val extras = android.os.Bundle()
+ extras.putBoolean("android.requestPromotedOngoing", true)
+ extras.putBoolean("android.substituteContextualActions", true)
+ distanceKm?.let { extras.putString("android.shortCriticalText", String.format("%.0f%%", progress.toFloat())) }
+ builder.addExtras(extras)
+
+ builder.javaClass.getMethod("setRequestPromotedOngoing", Boolean::class.javaPrimitiveType)
+ .invoke(builder, true)
+
+ distanceKm?.let {
+ builder.javaClass.getMethod("setShortCriticalText", CharSequence::class.java)
+ .invoke(builder, String.format("%.0f%%", progress.toFloat()))
+ }
+ } catch (_: Throwable) {}
+
+ return builder.build()
+ }
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.rounded_navigation_24)
+ .setContentTitle(getString(R.string.location_reached_service_title))
+ .setContentText(contentText)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setContentIntent(mainPendingIntent)
+ .setProgress(100, progress, false)
+ .addAction(R.drawable.rounded_power_settings_new_24, getString(R.string.location_reached_stop_tracking), stopPendingIntent)
+
+ val extras = android.os.Bundle()
+ extras.putBoolean("android.requestPromotedOngoing", true)
+ distanceKm?.let { extras.putString("android.shortCriticalText", String.format("%d%%", progress)) }
+ builder.addExtras(extras)
+
+ return builder.build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.location_reached_channel_name),
+ NotificationManager.IMPORTANCE_HIGH // Increased importance
+ ).apply {
+ description = getString(R.string.location_reached_channel_desc)
+ setShowBadge(false)
+ setLockscreenVisibility(Notification.VISIBILITY_PUBLIC) // Ensure it's visible on lockscreen
+ setSound(null, null)
+ enableVibration(false)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Float {
+ val r = 6371e3
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+ return (r * c).toFloat()
+ }
+
+ override fun onDestroy() {
+ trackingJob?.cancel()
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt
index 45084ef4..89deb12f 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/NotificationListener.kt
@@ -22,6 +22,11 @@ class NotificationListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) {
val prefs = applicationContext.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE)
+ // Maps navigation state update
+ if (sbn.packageName == "com.google.android.apps.maps") {
+ MapsState.hasNavigationNotification = isNavigationNotification(sbn)
+ }
+
// Handle Snooze System Notifications
try {
val packageName = sbn.packageName
@@ -177,9 +182,6 @@ class NotificationListener : NotificationListenerService() {
// ignore failures
}
- if (sbn.packageName == "com.google.android.apps.maps") {
- MapsState.hasNavigationNotification = isNavigationNotification(sbn)
- }
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
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
new file mode 100644
index 00000000..752d8de0
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt
@@ -0,0 +1,227 @@
+package com.sameerasw.essentials.ui.activities
+
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.VibrationEffect
+import android.os.Vibrator
+import android.os.VibratorManager
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.enableEdgeToEdge
+import androidx.core.view.WindowCompat
+import androidx.activity.compose.setContent
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Brush
+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 androidx.compose.ui.unit.sp
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.data.repository.LocationReachedRepository
+import com.sameerasw.essentials.services.LocationReachedService
+import kotlinx.coroutines.delay
+
+class LocationAlarmActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ enableEdgeToEdge()
+ showWhenLockedAndTurnScreenOn()
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ com.sameerasw.essentials.ui.theme.EssentialsTheme {
+ LocationAlarmScreen(onFinish = {
+ stopAlarmAndFinish()
+ })
+ }
+ }
+
+ startUrgentVibration()
+ }
+
+ override fun onStop() {
+ super.onStop()
+ stopAlarmAndFinish()
+ }
+
+ override fun onUserLeaveHint() {
+ super.onUserLeaveHint()
+ stopAlarmAndFinish()
+ }
+
+ private fun showWhenLockedAndTurnScreenOn() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true)
+ setTurnScreenOn(true)
+ } else {
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
+ )
+ }
+
+ // Keyguard dismissal
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ keyguardManager.requestDismissKeyguard(this, null)
+ } else {
+ window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
+ }
+
+ // Keep screen on
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+
+ private fun startUrgentVibration() {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+ vibratorManager.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val pattern = longArrayOf(0, 500, 200, 500, 200, 1000)
+ val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
+ vibrator.vibrate(VibrationEffect.createWaveform(pattern, amplitudes, 1))
+ } else {
+ @Suppress("DEPRECATION")
+ vibrator.vibrate(longArrayOf(0, 500, 200, 500), 0)
+ }
+ }
+
+ private fun stopAlarmAndFinish() {
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
+ vibratorManager.defaultVibrator
+ } else {
+ @Suppress("DEPRECATION")
+ getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+ }
+ try {
+ vibrator.cancel()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ // Disable alarm in repo
+ val repo = LocationReachedRepository(this)
+ val alarm = repo.getAlarm()
+ repo.saveAlarm(alarm.copy(isEnabled = false))
+
+ // Stop the progress service
+ LocationReachedService.stop(this)
+
+ if (!isFinishing) {
+ finish()
+ }
+ }
+}
+
+// @androidx.compose.ui.tooling.preview.Preview(showBackground = true)
+// @Composable
+// fun LocationAlarmScreenPreview() {
+// LocationAlarmScreen(onFinish = {})
+// }
+
+@Composable
+fun LocationAlarmScreen(onFinish: () -> Unit) {
+ val infiniteTransition = rememberInfiniteTransition(label = "alarm")
+
+ val scale by infiniteTransition.animateFloat(
+ initialValue = 1f,
+ targetValue = 1.15f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(800, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "scale"
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(200.dp)
+ .scale(scale)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_location_on_24),
+ contentDescription = null,
+ modifier = Modifier.size(100.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ Spacer(modifier = Modifier.height(48.dp))
+
+ Text(
+ text = stringResource(R.string.location_reached_alarm_title),
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontWeight = FontWeight.Black,
+ letterSpacing = 2.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = stringResource(R.string.location_reached_alarm_subtitle),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Spacer(modifier = Modifier.height(80.dp))
+
+ Button(
+ onClick = onFinish,
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .height(64.dp),
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_mobile_check_24),
+ contentDescription = null
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = stringResource(R.string.location_reached_dismiss),
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold)
+ )
+ }
+ }
+ }
+}
+
+// preview
+@androidx.compose.ui.tooling.preview.Preview(showBackground = true)
+@Composable
+fun LocationAlarmScreenPreview() {
+ LocationAlarmScreen(onFinish = {})
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/DIYFloatingToolbar.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/DIYFloatingToolbar.kt
index 144d8ea2..e4551d89 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/components/DIYFloatingToolbar.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/DIYFloatingToolbar.kt
@@ -88,7 +88,10 @@ fun DIYFloatingToolbar(
},
expanded = expanded,
scrollBehavior = scrollBehavior,
- colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(),
+ colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(
+ toolbarContentColor = MaterialTheme.colorScheme.onSurface,
+ toolbarContainerColor = MaterialTheme.colorScheme.primary,
+ ),
content = {
// FIXED ORDER LOOP to prevent shifting
tabs.forEachIndexed { index, tab ->
@@ -159,9 +162,15 @@ fun DIYFloatingToolbar(
alpha = itemAlpha
},
colors = if (isSelected) {
- IconButtonDefaults.filledTonalIconButtonColors()
+ IconButtonDefaults.filledIconButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary,
+ containerColor = MaterialTheme.colorScheme.background
+ )
} else {
- IconButtonDefaults.iconButtonColors()
+ IconButtonDefaults.iconButtonColors(
+ contentColor = MaterialTheme.colorScheme.background,
+ containerColor = MaterialTheme.colorScheme.primary
+ )
}
) {
Icon(
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt
index 42892f47..80c1b9e6 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/ReusableTopAppBar.kt
@@ -1,8 +1,10 @@
package com.sameerasw.essentials.ui.components
import androidx.compose.foundation.background
+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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
@@ -47,6 +49,7 @@ fun ReusableTopAppBar(
hasHelp: Boolean = false,
scrollBehavior: TopAppBarScrollBehavior? = null,
subtitle: Any? = null, // Can be Int or String
+ isBeta: Boolean = false,
actions: @Composable RowScope.() -> Unit = {}
) {
val collapsedFraction = scrollBehavior?.state?.collapsedFraction ?: 0f
@@ -69,11 +72,31 @@ fun ReusableTopAppBar(
if (subtitle != null) {
// Show title and subtitle
Column {
- Text(
- resolvedTitle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ resolvedTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (isBeta) {
+ androidx.compose.material3.Card(
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ ),
+ 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.onPrimary
+ )
+ }
+ }
+ }
val resolvedSubtitle = when (subtitle) {
is Int -> stringResource(id = subtitle)
is String -> subtitle
@@ -89,11 +112,31 @@ fun ReusableTopAppBar(
}
} else {
// Show only title
- Text(
- resolvedTitle,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ resolvedTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (isBeta) {
+ androidx.compose.material3.Card(
+ colors = androidx.compose.material3.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
+ )
+ }
+ }
+ }
}
},
navigationIcon = {
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 446438d3..1d72914b 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
@@ -39,7 +39,8 @@ fun FeatureCard(
showToggle: Boolean = true,
onDisabledToggleClick: (() -> Unit)? = null,
description: Any? = null, // Can be Int or String
- descriptionOverride: String? = null // For cases where we search and prepend parent feature name
+ descriptionOverride: String? = null, // For cases where we search and prepend parent feature name
+ isBeta: Boolean = false
) {
val view = LocalView.current
@@ -56,6 +57,12 @@ fun FeatureCard(
.fillMaxWidth()
.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,
@@ -64,11 +71,6 @@ fun FeatureCard(
if (iconRes != null) {
Spacer(modifier = Modifier.size(1.dp))
- val resolvedTitle = when (title) {
- is Int -> stringResource(id = title)
- is String -> title
- else -> ""
- }
Icon(
painter = painterResource(id = iconRes),
contentDescription = resolvedTitle,
@@ -81,12 +83,27 @@ fun FeatureCard(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
- val resolvedTitle = when (title) {
- is Int -> stringResource(id = title)
- is String -> title
- else -> ""
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(text = resolvedTitle)
+ 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
+ )
+ }
+ }
}
- Text(text = resolvedTitle)
if (descriptionOverride != null) {
Text(
text = descriptionOverride,
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt
index d9145cb7..a11f6395 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/SetupFeatures.kt
@@ -712,6 +712,7 @@ fun SetupFeatures(
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
showToggle = false,
hasMoreSettings = true,
+ isBeta = result.isBeta, // Added isBeta
descriptionOverride = if (result.parentFeature != null) "${result.parentFeature} > ${result.description}" else result.description
)
}
@@ -773,7 +774,8 @@ fun SetupFeatures(
currentFeature = feature.title
showSheet = true
},
- description = feature.description
+ description = feature.description,
+ isBeta = feature.isBeta
)
}
}
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
new file mode 100644
index 00000000..df7d626d
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt
@@ -0,0 +1,299 @@
+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.*
+import androidx.compose.material3.*
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.runtime.*
+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.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.google.android.material.loadingindicator.LoadingIndicator
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.ui.components.cards.IconToggleItem
+import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
+import com.sameerasw.essentials.viewmodels.LocationReachedViewModel
+import com.sameerasw.essentials.viewmodels.MainViewModel
+import com.sameerasw.essentials.domain.model.LocationAlarm
+
+@OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun LocationReachedSettingsUI(
+ mainViewModel: MainViewModel,
+ modifier: Modifier = Modifier,
+ highlightSetting: String? = null
+) {
+ val context = LocalContext.current
+ val locationViewModel: LocationReachedViewModel = viewModel()
+ val alarm by locationViewModel.alarm
+ val distance by locationViewModel.currentDistance
+ val isProcessing by locationViewModel.isProcessingCoordinates
+ val startDistance by locationViewModel.startDistance
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (isProcessing) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.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
+ )
+ }
+ } else if (alarm.latitude != 0.0 && alarm.longitude != 0.0) {
+ // Destination Set State
+ RoundedCardContainer(
+ modifier = Modifier,
+ cornerRadius = 24.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceBright,
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(24.dp)
+ )
+ .padding(20.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))
+
+ 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))
+ }
+
+ } else {
+ // READY STATE (Not Tracking)
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_my_location_24),
+ contentDescription = null,
+ modifier = Modifier.size(48.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
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "${alarm.latitude}, ${alarm.longitude}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ 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))
+
+ // Secondary Actions
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ 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")
+ context.startActivity(mapIntent)
+ },
+ 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(18.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(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.location_reached_clear))
+ }
+ }
+ }
+ }
+ } else {
+ // Empty State
+ RoundedCardContainer(
+ modifier = Modifier.fillMaxWidth(),
+ cornerRadius = 24.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp).fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_add_location_alt_24),
+ contentDescription = null,
+ modifier = Modifier.size(64.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")
+ context.startActivity(mapIntent)
+ },
+ shape = androidx.compose.foundation.shape.CircleShape,
+ colors = ButtonDefaults.filledTonalButtonColors()
+ ) {
+ Text(stringResource(R.string.location_reached_open_maps))
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.location_reached_radius_title, alarm.radius),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, bottom = 8.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ RoundedCardContainer(
+ modifier = Modifier,
+ cornerRadius = 24.dp
+ ) {
+ 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
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.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(24.dp)
+ ),
+ cornerRadius = 24.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
+ )
+ }
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
index c93b29eb..e76763a3 100644
--- a/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
+++ b/app/src/main/java/com/sameerasw/essentials/utils/PermissionUtils.kt
@@ -104,4 +104,31 @@ object PermissionUtils {
// Fallback or ignore
}
}
+
+ fun hasLocationPermission(context: Context): Boolean {
+ return androidx.core.content.ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.ACCESS_FINE_LOCATION
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ }
+
+ fun hasBackgroundLocationPermission(context: Context): Boolean {
+ return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ androidx.core.content.ContextCompat.checkSelfPermission(
+ context,
+ android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ } else {
+ true
+ }
+ }
+
+ fun canUseFullScreenIntent(context: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
+ nm.canUseFullScreenIntent()
+ } else {
+ true
+ }
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt
new file mode 100644
index 00000000..c8050e2b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt
@@ -0,0 +1,247 @@
+package com.sameerasw.essentials.viewmodels
+
+import android.app.Application
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.location.Priority
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.data.repository.LocationReachedRepository
+import com.sameerasw.essentials.domain.model.LocationAlarm
+import com.sameerasw.essentials.services.LocationReachedService
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.HttpURLConnection
+import java.net.URL
+import kotlin.math.*
+
+class LocationReachedViewModel(application: Application) : AndroidViewModel(application) {
+ private val repository = LocationReachedRepository(application)
+ private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application)
+
+ var alarm = mutableStateOf(repository.getAlarm())
+ private set
+
+ var isProcessingCoordinates = mutableStateOf(false)
+ private set
+
+ var currentDistance = mutableStateOf(null)
+ private set
+
+ var startDistance = mutableStateOf(repository.getStartDistance())
+ private set
+
+ init {
+ startDistanceTracking()
+
+ // Observe shared state for real-time updates across activities
+ viewModelScope.launch {
+ LocationReachedRepository.isProcessing.collect {
+ isProcessingCoordinates.value = it
+ }
+ }
+
+ viewModelScope.launch {
+ LocationReachedRepository.alarmFlow.collect { newAlarm ->
+ newAlarm?.let {
+ alarm.value = it
+ // Start distance might need refresh if destination changed
+ }
+ }
+ }
+ }
+
+ 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 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 stopTracking() {
+ val currentAlarm = alarm.value
+ val disabledAlarm = currentAlarm.copy(isEnabled = false)
+ alarm.value = disabledAlarm
+ repository.saveAlarm(disabledAlarm)
+ LocationReachedService.stop(getApplication())
+ // Keep start distance for potential restart? Or maybe just keep coordinates.
+ // User said "keep last track in memory (only destination)".
+ }
+
+ private fun startDistanceTracking() {
+ viewModelScope.launch {
+ while (true) {
+ // Tracking should only happen if enabled? Or just if coordinates exist?
+ // Logic: Distance displayed in UI needs coords. Service needs enabled.
+ if (alarm.value.latitude != 0.0 && alarm.value.longitude != 0.0) {
+ updateCurrentDistance()
+ } else {
+ currentDistance.value = null
+ }
+ delay(10000) // Update every 10 seconds
+ }
+ }
+ }
+
+ @android.annotation.SuppressLint("MissingPermission")
+ private fun updateCurrentDistance() {
+ 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
+ )
+ currentDistance.value = distance
+ }
+ }
+ }
+
+ private 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
+ val deltaPhi = (lat2 - lat1) * PI / 180
+ val deltaLambda = (lon2 - lon1) * PI / 180
+
+ val a = sin(deltaPhi / 2).pow(2) +
+ cos(phi1) * cos(phi2) *
+ sin(deltaLambda / 2).pow(2)
+ val c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return (r * c).toFloat()
+ }
+
+ fun updateAlarm(newAlarm: LocationAlarm) {
+ alarm.value = newAlarm
+ repository.saveAlarm(newAlarm)
+
+ // If updating radius while enabled, ensure service stays up or is updated?
+ // Service just reads from repo loop, so saving is enough.
+ }
+
+ fun handleIntent(intent: android.content.Intent): Boolean {
+ val action = intent.action
+ val type = intent.type
+ val data = intent.data
+
+ android.util.Log.d("LocationReachedVM", "handleIntent: action=$action, type=$type, data=$data")
+
+ val textToParse = when {
+ action == android.content.Intent.ACTION_SEND && type == "text/plain" -> {
+ intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
+ }
+ action == android.content.Intent.ACTION_VIEW && data?.scheme == "geo" -> {
+ data.toString()
+ }
+ action == android.content.Intent.ACTION_VIEW && (data?.host?.contains("google.com") == true || data?.host?.contains("goo.gl") == true) -> {
+ data.toString()
+ }
+ else -> null
+ }
+
+ if (textToParse == null) return false
+
+ // Check if it's a shortened URL that needs resolution
+ if (textToParse.contains("maps.app.goo.gl") || textToParse.contains("goo.gl/maps")) {
+ resolveAndParse(textToParse)
+ return true // Navigate to settings while resolving
+ }
+
+ 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)
+
+ if (match != null) {
+ val lat = match.groupValues[1].toDoubleOrNull() ?: 0.0
+ val lng = match.groupValues[2].toDoubleOrNull() ?: 0.0
+
+ 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.setIsProcessing(false)
+ return true
+ }
+ }
+ android.util.Log.d("LocationReachedVM", "No coordinates found in text: $text")
+ repository.setIsProcessing(false)
+ return false
+ }
+
+ private fun resolveAndParse(shortUrl: String) {
+ repository.setIsProcessing(true)
+ viewModelScope.launch {
+ val resolvedUrl = withContext(Dispatchers.IO) {
+ try {
+ val url = URL(shortUrl)
+ val connection = url.openConnection() as HttpURLConnection
+ connection.instanceFollowRedirects = false
+ connection.connect()
+ val location = connection.getHeaderField("Location")
+ connection.disconnect()
+ location ?: shortUrl
+ } catch (e: Exception) {
+ android.util.Log.e("LocationReachedVM", "Error resolving URL", e)
+ shortUrl
+ }
+ }
+ 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.setIsProcessing(false)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt
index 853200e3..8b696683 100644
--- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt
+++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt
@@ -80,6 +80,9 @@ class MainViewModel : ViewModel() {
val flashlightLastIntensity = mutableStateOf(1)
val isFlashlightPulseEnabled = mutableStateOf(false)
val isFlashlightPulseFacedownOnly = mutableStateOf(true)
+ val isLocationPermissionGranted = mutableStateOf(false)
+ val isBackgroundLocationPermissionGranted = mutableStateOf(false)
+ val isFullScreenIntentPermissionGranted = mutableStateOf(false)
@@ -171,6 +174,9 @@ class MainViewModel : ViewModel() {
isOverlayPermissionGranted.value = PermissionUtils.canDrawOverlays(context)
isNotificationLightingAccessibilityEnabled.value = PermissionUtils.isNotificationLightingAccessibilityServiceEnabled(context)
isDefaultBrowserSet.value = PermissionUtils.isDefaultBrowser(context)
+ isLocationPermissionGranted.value = PermissionUtils.hasLocationPermission(context)
+ isBackgroundLocationPermissionGranted.value = PermissionUtils.hasBackgroundLocationPermission(context)
+ isFullScreenIntentPermissionGranted.value = PermissionUtils.canUseFullScreenIntent(context)
settingsRepository.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
settingsRepository.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
@@ -608,6 +614,24 @@ class MainViewModel : ViewModel() {
)
}
+ fun requestLocationPermission(activity: androidx.activity.ComponentActivity) {
+ androidx.core.app.ActivityCompat.requestPermissions(
+ activity,
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
+ 1003
+ )
+ }
+
+ fun requestBackgroundLocationPermission(activity: androidx.activity.ComponentActivity) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ androidx.core.app.ActivityCompat.requestPermissions(
+ activity,
+ arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
+ 1004
+ )
+ }
+ }
+
fun requestNotificationPermission(activity: androidx.activity.ComponentActivity) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
androidx.core.app.ActivityCompat.requestPermissions(
@@ -618,6 +642,23 @@ class MainViewModel : ViewModel() {
}
}
+ fun requestFullScreenIntentPermission(context: Context) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ try {
+ val intent = Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
+ data = android.net.Uri.fromParts("package", context.packageName, null)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ // Fallback to special app access
+ val intent = Intent(Settings.ACTION_CONDITION_PROVIDER_SETTINGS)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+ }
+ }
+
private fun hasNotificationListenerPermission(context: Context): Boolean {
return PermissionUtils.hasNotificationListenerPermission(context)
}
diff --git a/app/src/main/res/drawable/rounded_add_location_alt_24.xml b/app/src/main/res/drawable/rounded_add_location_alt_24.xml
new file mode 100644
index 00000000..a759d6ce
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_add_location_alt_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_delete_24.xml b/app/src/main/res/drawable/rounded_delete_24.xml
new file mode 100644
index 00000000..7a1c4d87
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_delete_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_location_on_24.xml b/app/src/main/res/drawable/rounded_location_on_24.xml
new file mode 100644
index 00000000..41120959
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_location_on_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_map_24.xml b/app/src/main/res/drawable/rounded_map_24.xml
new file mode 100644
index 00000000..97b61e1b
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_map_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_my_location_24.xml b/app/src/main/res/drawable/rounded_my_location_24.xml
new file mode 100644
index 00000000..bc90c2b7
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_my_location_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_pause_24.xml b/app/src/main/res/drawable/rounded_pause_24.xml
new file mode 100644
index 00000000..76350ba6
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_pause_24.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 49379cc7..5c16a1ba 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -8,7 +8,7 @@
Apps einfrieren
Öffne \"Apps einfrieren\"
Eingefrorene App
- Leeres Display-aus-Widget
+ Leeres Bildschirm-aus-Widget
Apps einfrieren
Taschenlampenimpuls
Prüfe nach Vorabversionen
@@ -21,8 +21,8 @@
Authentifiziere dich, um App-Sperre zu deaktivieren
Wähle gesperrte Apps
Wähle, welche Apps eine Authentifizierung benötigen
- Schütze deine Apps mit biometrischer Authentifizierung. Gesperrte Apps erfordern beim Öffnen eine Authentifizierung und bleiben entsperrt, bis das Display ausgeschaltet wird.
- Beachte bitte, dass dies keine zuverlässige Lösung darstellt, da es sich lediglich um eine Drittanbieteranwendung handelt. Für erhöhte Sicherheitsanforderungen solltest du das vertrauliche Profil oder ähnliche Funktionen in Betracht ziehen.
+ Schütze deine Apps mit biometrischer Authentifizierung. Gesperrte Apps erfordern beim Öffnen eine Authentifizierung und bleiben entsperrt, bis der Bildschirm ausgeschaltet wird.
+ Beachte bitte, dass dies keine zuverlässige Lösung darstellt, da es sich lediglich um eine Drittanbieteranwendung handelt. Für erhöhte Sicherheitsanforderungen sollten Sie das vertrauliche Profil oder ähnliche Funktionen in Betracht ziehen.
Noch ein Hinweis: Die biometrische Authentifizierungsaufforderung unterstützt ausschließlich sichere Methoden der Klasse STARK. Gesichtserkennungsmethoden der Klasse SCHWACH auf Geräten wie dem Pixel 7 können nur die sonstig verfügbaren STARK-Authentifizierungsmethoden wie Fingerabdruck oder PIN nutzen.
Aktiviere Tastenneubelegung
@@ -108,11 +108,11 @@
Taschenlampenimpuls
Nur, wenn das Handy umgedreht ist
Stil
- Streifeneffekt anpassen
+ Aufleuchten anpassen
Eckradius
- Streifeneffekt Dicke
- Leuchteffekt anpassen
- Leuchteffekt Weite
+ Dicke
+ Leuchten anpassen
+ Weite
Platzierung
Horizontale Position
Vertikale Position
@@ -154,7 +154,7 @@
Authentifiziere dich, um Sicherheit bei Bildschirmsperre zu aktivieren
Authentifiziere dich, um Sicherheit bei Bildschirmsperre zu deaktivieren
⚠️ WARNUNG
- Diese Funktion ist nicht absolut sicher. Es kann Ausnahmefälle geben, in denen jemand dennoch mit der Kachel interagieren kann. \nBeachte auch, dass Android immer einen erzwungenen Neustart zulässt und Pixel-Handys immer das Ausschalten des Geräts über den Sperrbildschirm zulassen.
+ Diese Funktion ist nicht absolut sicher. Es kann Ausnahmefälle geben, in denen jemand dennoch mit der Kachel interagieren kann. \nBeachten Sie auch, dass Android immer einen erzwungenen Neustart zulässt und Pixel-Handys immer das Ausschalten des Geräts über den Sperrbildschirm zulassen.
Stelle sicher, dass du die Kachel für den Flugmodus aus den Schnelleinstellungen entfernst, da dies nicht verhindert werden kann, da kein Dialogfenster geöffnet wird.
Wenn diese Option aktiviert ist, werden die Schnelleinstellungen sofort geschlossen und das Gerät komplett gesperrt, sobald jemand versucht, mit Internetkacheln zu interagieren, während das Gerät gesperrt ist. \n\nDadurch wird auch die biometrische Entsperrung deaktiviert, um weiteren unbefugten Zugriff zu verhindern. Die Animationsskala wird während der Sperrung auf 0,1x reduziert, um die Interaktion noch weiter zu erschweren.
@@ -271,7 +271,7 @@
Benachrichtigung anzeigen
Zeige eine dauerhafte Benachrichtigung an, wenn Koffein aktiv ist
Lichtstil
- Wähle zwischen Streifeneffekt, Leuchteffekt, Dreheffekt und mehr
+ Wählen Sie zwischen Streifeneffekt, Leuchteffekt, Dreheffekt und mehr
Eckradius
Passe den Eckradius des Benachrichtigungslicht an
Stille Benachrichtigungen überspringen
@@ -282,7 +282,7 @@
Die Taschenlampe blinkt nur, wenn das Gerät mit der Vorderseite nach unten liegt
Debugging-Benachrichtigungen deaktivieren
Persistente ADB/USB-Debugging-Benachrichtigungen ausblenden
- Dateiübertrage-Benachrichtigung deaktivieren
+ Benachrichtigung zur Dateiübertragung deaktivieren
Persistente Benachrichtigungen zur USB-Dateiübertragung ausblenden
Lade-Benachrichtigung deaktivieren
Persistente Benachrichtigungen zum Aufladen ausblenden
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index a61e60d7..d41966f0 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -596,7 +596,7 @@
Confirmez votre identité pour ouvrir les paramètres
Authentification requise
Confirmez votre identité
- Déverouillez le téléphone pour changer les paramètres réseaux
+ Déverrouillez le téléphone pour changer les paramètres réseaux
Développé par %1$s\n avec ❤\uFE0F de \uD83C\uDDF1\uD83C\uDDF0D
Site web
@@ -612,7 +612,7 @@
Avatar du développeur
Help & Guides
- Besoin de plus d\'aide? Contactez-nous,
+ Besoin de plus d\'aide ? Contactez-nous,
Réduire
Étendre
Groupe de support
@@ -624,14 +624,14 @@
Accessibility, Notification and Overlay permissions
You may get this access denied message if you try to grant sensitive permissions such as accessibility, notification listener or overlay permissions. To grant it, check the steps below.
1. Allez dans la page d\'informations de l\'application Essentials
- 2. Ouvrez le menu à trois points en haut à droite et sélectionnez \"Autoriser les paramètres restreints\". Vous devrez peut-être vous authentifier avec la biométrie. Une fois fait, réessayez d\'accorder la permission.
+ 2. Ouvrez le menu à 3 points en haut à droite et sélectionnez \"Autoriser les paramètres restreints\". Vous devrez peut-être vous authentifier avec la biométrie. Une fois fait, réessayez d\'accorder la permission.
Shizuku
Shizuku is a powerful tool that allows apps to use system APIs directly with ADB or root permissions. It is required for features like Maps min mode, App Freezer. And willa ssist granting some permissions such as WRITE_SECURE_SETTINGS. \n\nBut the Play Store version of Shizuku might be outdated and will probably be unusable on recent Android versions so in that case, please get the latest version from the github or an up-to-date fork of it.
Maps power saving mode
This feature automatically triggers Google Maps power saving mode which is currently exclusive to the Pixel 10 series. A community member discovered that it is still usable on any Android device by launching the maps minMode activity with root privileges. \n\nAnd then, I had it automated with Tasker to automatically trigger when the screen turns off during a navigation session and then was able to achieve the same with just runtime Shizuku permissions. \n\nIt is intended to be shown over the AOD of Pixel 10 series so because of that, you may see an occasional message popping up on the display that it does not support landscape mode. That is not avoidable by the app and you can ignore.
Mode silencieux
You may have noticed that the silent mode also triggers DND. \n\nThis is due to how the Android implemented it as even if we use the same API to switch to vibrate mode, it for some reason turns on DND along with the silent mode and this is not avoidable at this moment. :(
- Qu\'est-ce que le gel d\'applications ?
+ Qu\'est-ce que le gel ?
Pause and stay away from app distractions while saving a little bit of power preventing apps running in the background. Suitable for rarely used apps. \n\nNot recommended for any communication services as they will not notify you in an emergency unless you unfreeze them. \n\nHighly advised to not freeze system apps as they can lead to system instability. Proceed with caution, You were warned. \n\nInspired by Hail <3
Are app lock and screen locked security actually secure?
Absolutely not. \n\nAny 3rd party application can not 100% interfere with regular device interactions and even the app lock is only an overlay above selected apps to prevent interacting with them. There are workarounds and it is not foolproof. \n\nSame goes with the screen locked security feature which detects someone trying to interact with the network tiles which for some reason are still accessible for anyone on Pixels. So if they try hard enough they might still be able to change them and especially if you have a flight mode QS tile added, this app can not prevent interactions with it. \n\nThese features are made just as experiments for light usage and would never recommend as strong security and privacy solutions. \n\nSecure alternatives:\n - App lock: Private Space and Secure folder on Pixels and Samsung\n - Preventing mobile networks access: Make sure your theft protection and offline/ power off find my device settings are on. You may look into Graphene OS as well.
@@ -643,7 +643,7 @@
Some OEMs limit the accessibility service reporting once the display is actually off but they may still work while the AOD is on. \nIn this case, you may able to use button remaps with AOD on but not with off. \n\nAs a workaround, you will need to use Shizuku permissions and turn on the \'Use Shizuku\' toggle in button remap settings which identifies and listen to hardware input events.\nThis is not guaranteed to work on all devices and needs testing.\n\nAnd even if it\'s on, Shizuku method only will be used when it\'s needed. Otherwise it will always fallback to Accessibility which also handles the blocking of the actual input during long press.
Le contrôle de la luminosité de la lampe-torche ne marche pas
Only a limited number of devices got hardware and software support adjusting the flashlight intensity. \n\n\'The minimum version of Android is 13 (SDK33).\nFlashlight brightness control only supports HAL version 3.8 and higher, so among the supported devices, the latest ones (For example, Pixel 6/7, Samsung S23, etc.)\'\npolodarb/Flashlight-Tiramisu
- Qu\'est-ce que c\'est que cette application ?
+ C\'est quoi cette application ?
Good question,\n\nI always wanted to extract the most out of my devices as I\'ve been a rooted user for ever since I got my first Project Treble device. And I\'ve been loving the Tasker app which is like the god when comes automation and utilizing every possible API and internal features of Android.\n\nSo I am not unrooted and back on stock Android beta experience and wanted to get the most out from what is possible with given privileges. Might as well share them. So with my beginner knowledge in Kotlin Jetpack and with the support of many research and assist tools and also the great community, I built an all-in-one app containing everything I wanted to be in my Android with given permissions. And here it is.\n\nFeature requests are welcome, I will consider and see if they are achievable with available permissions and my skills. Nowadays what is not possible. :)\n\nWhy not on Play Store?\nI don\'t wanna risk getting my Developer account banned due to the highly sensitive and internal permissions and APIs being used in the app. But with the way Android sideloading is headed, let\'s see what we have to do. I do understand the concerns of sideloaded apps being malicious.\nWhile we are at the topic, Checkout my other app AirSync if you are a mac + Android user. *shameless plug*\n\nEnjoy, Keep building! (っ◕‿◕)っ
Signaler un bogue
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index d1c2dcbb..534ab340 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -4,7 +4,7 @@
Essentials Accessibility Service\n\nThis service is required for the following advanced features:\n\n• Physical Button Remapping:\nDetects volume button presses even when the screen is off to trigger actions like the Flashlight.\n\n• Per-App Settings:\nMonitors the currently active app to apply specific profiles for Dynamic Night Light, Notification Lighting Colors, and App Lock.\n\n• Screen Control:\nAllows the app to lock the screen (e.g. via Double Tap or Widgets) and detect screen state changes.\n\n• Security:\nPrevents unauthorized changes by detecting window content when the device is locked.\n\nNo input text or sensitive user data is collected or transmitted.
Icona applicazione
Blocco All
- Disable apps that are rarely used
+ Disabilità le app che sono usate raramente
Blocco App
Apri Blocco App
Blocco App
@@ -17,21 +17,21 @@
Sicurezza
Abilita blocco app
App Lock Security
- Authenticate to enable app lock
- Authenticate to disable app lock
+ Autenticati per abilitare il blocco app
+ Autenticati per disabilitare il blocco app
Select locked apps
- Choose which apps require authentication
+ Scegli quali app richiederanno l\'autenticazione
Blocca le tue app con l\'autenticazione biometrica.
Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà sbloccata fino allo spegnimento dello schermo.
Beware that this is not a robust solution as this is only a 3rd party application. If you need strong security, consider using Private Space or other such features.
Another note, the biometric authentication prompt only lets you use STRONG secure class methods. Face unlock security methods in WEAK class in devices such as Pixel 7 will only be able to utilize the available other STRONG auth methods such as fingerprint or pin.
- Enable Button Remap
+ Abilita la rimappatura dei tasti
Usa Shizuku
Works with screen off (Recommended)
Shizuku non è in esecuzione
- Detected %1$s
- Status: %1$s
+ Rilevato %1$s
+ Stato: %1$s
Apri Shizuku
Torcia
Impostazioni torcia
@@ -47,8 +47,8 @@ Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà s
Media play/pause
Media next
Media previous
- Toggle vibrate
- Toggle mute
+ Attiva/Disattiva la vibrazione
+ Attiva/Disattiva il silenzioso
Assistente AI
Fai uno screenshot
When the screen is off, long-press the selected button to trigger its assigned action. On Pixel devices, this action only gets triggered if the AOD is on due to system limitations.
@@ -62,8 +62,8 @@ Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà s
Volume + - adjusts flashlight intensity
Live update
Show brightness in status bar
- Other
- Always turn off flashlight
+ Altro
+ Spegni sempre la torcia
Even while display is on
Impostazioni
@@ -79,17 +79,17 @@ Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà s
Seleziona app
App Control
- Freeze
- Unfreeze
+ Blocca
+ Sblocca
Altre opzioni
Blocca tutte le app
Sblocca tutte le app
- Pick apps to freeze
+ Scegli le app da bloccare
Choose which apps can be frozen
Automation
Freeze when locked
Freeze delay
- Immediate
+ Subito
1m
5m
15m
@@ -119,7 +119,7 @@ Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà s
Vertical position
Indicator adjustment
Scale
- Duration
+ Durata
Animation
Pulse count
Pulse duration
@@ -131,9 +131,9 @@ Le app bloccate richiederanno l\'autenticazione all\'apertura. L\'app rimarrà s
Show lock screen
No black overlay
- Add
- Already added
- Requires Android 13+
+ Aggiungi
+ Già aggiunto
+ Richiede Android 13 o successivo
UI Blur
Bubbles
Sensitive Content
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c26bee01..48f1b176 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,5 +1,6 @@
Essentials
+ BETA
Essentials Accessibility Service\n\nThis service is required for the following advanced features:\n\n• Physical Button Remapping:\nDetects volume button presses even when the screen is off to trigger actions like the Flashlight.\n\n• Per-App Settings:\nMonitors the currently active app to apply specific profiles for Dynamic Night Light, Notification Lighting Colors, and App Lock.\n\n• Screen Control:\nAllows the app to lock the screen (e.g. via Double Tap or Widgets) and detect screen state changes.\n\n• Security:\nPrevents unauthorized changes by detecting window content when the device is locked.\n\nNo input text or sensitive user data is collected or transmitted.
App icon
App Freezing
@@ -708,4 +709,46 @@
Copy to Clipboard
Essentials Bug Report
Send via
+
+
+ Are we there yet?
+ Destination nearby alerts
+ Destination
+ Set Destination
+ Tracking: %1$.4f, %2$.4f
+ No destination set
+ Open Google Maps, pick a location, and share it to Essentials.
+ Alert Radius: %d m
+ Distance to trigger the alarm
+ Enable notification
+ Location
+ Used to detect arrival at your destination.
+ Background Location
+ Required to monitor your arrival while the app is closed or the screen is off.
+ Destination Reached!
+ You have arrived at your destination.
+ Currently %1$.1f km away
+ Clear Destination
+ Processing location…
+ DISTANCE REMAINING
+ Calculating…
+ Stop Tracking
+ Destination Ready
+ Start Tracking
+ View Map
+ Clear
+ No Destination
+ Open Maps
+ Full-Screen Alarm Permission
+ Required to wake your device upon arrival. Tap to grant.
+ %1$d m
+ %1$.1f km
+ Travel Alarm active
+ %1$s remaining (%2$d%%)
+ Travel Progress
+ Shows real-time distance to destination
+ Destination Nearby
+ Prepare to get off
+ Dismiss
+ Destination set: %1$.4f, %2$.4f
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 2b961481..14855b48 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -14,4 +14,10 @@
- true
- @null
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b7854ecc..ee5dedc0 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,7 +4,6 @@ kotlin = "2.3.0"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
-espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0"
composeBom = "2024.10.01"
@@ -14,12 +13,13 @@ hiddenapibypass = "4.3"
foundationLayout = "1.10.0"
ui = "1.10.0"
foundation = "1.10.0"
+playServicesLocation = "21.3.0"
+material = "1.13.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
-androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
@@ -38,6 +38,8 @@ hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypa
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }