diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f619511d..74824ecb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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()
}
@@ -520,6 +542,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/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/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt
index 87454ca7..25600559 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,19 @@ 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
+ ) {
+ 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/services/LocationReachedService.kt b/app/src/main/java/com/sameerasw/essentials/services/LocationReachedService.kt
new file mode 100644
index 00000000..26fa2f48
--- /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) String.format("%d m", (it * 1000).toInt())
+ else String.format("%.1f km", it)
+ } ?: "Calculating..."
+
+ val contentText = "$distanceText remaining ($progress%)"
+
+ if (Build.VERSION.SDK_INT >= 35) {
+ val builder = Notification.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.rounded_navigation_24)
+ .setContentTitle("Travel Alarm active")
+ .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),
+ "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("Travel Alarm active")
+ .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, "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,
+ "Travel Progress",
+ NotificationManager.IMPORTANCE_HIGH // Increased importance
+ ).apply {
+ description = "Shows real-time distance to destination"
+ 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/ui/activities/LocationAlarmActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/LocationAlarmActivity.kt
new file mode 100644
index 00000000..4e1aac87
--- /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 = "Destination Nearby",
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontWeight = FontWeight.Black,
+ letterSpacing = 2.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = "Prepare to get off",
+ 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 = "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/composables/configs/LocationReachedSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt
new file mode 100644
index 00000000..48e7304f
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/LocationReachedSettingsUI.kt
@@ -0,0 +1,298 @@
+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 = "Processing location...",
+ 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) "${it.toInt()}m" else String.format("%.1fkm", it / 1000f)
+ } ?: "Calculating..."
+
+ Text(
+ text = "DISTANCE 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("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 = "Destination 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("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("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("Clear")
+ }
+ }
+ }
+ }
+ } else {
+ // Empty State
+ RoundedCardContainer(
+ modifier = Modifier.fillMaxWidth(),
+ cornerRadius = 24.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp),
+ 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 = "No Destination",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Share a location from Google Maps to start.",
+ 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("Open Maps")
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "Alarm Radius: ${alarm.radius}m",
+ 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 = "Full-Screen Alarm Permission",
+ description = "Required to wake your device upon arrival. Tap to grant.",
+ 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..3291328b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/LocationReachedViewModel.kt
@@ -0,0 +1,245 @@
+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.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(), "Destination 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(), "Destination 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/strings.xml b/app/src/main/res/values/strings.xml
index c26bee01..aa0fc3b1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -708,4 +708,24 @@
Copy to Clipboard
Essentials Bug Report
Send via
+
+
+ Location reached
+ Get notified when you arrive at a specific destination.
+ Destination
+ Set Destination
+ Tracking: %1$.4f, %2$.4f
+ No destination set
+ Open your map app, pick a location, and share it to Essentials.
+ 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
\ 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" }