diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 129eec7..df32b79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,11 @@ fun getSigningSecret(entryName: String, propertyKey: String, envVar: String): St } android { + sourceSets { + // MigrationTestHelper reads the exported schemas from the test APK's assets. + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } + namespace = "dev.arrase.geotify" compileSdk = 37 @@ -49,8 +54,8 @@ android { applicationId = "dev.arrase.geotify" minSdk = 24 targetSdk = 36 - versionCode = 14 - versionName = "1.0.0-beta.7" + versionCode = 15 + versionName = "1.0.0-beta.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -103,6 +108,13 @@ ksp { } dependencies { + // room-testing's generated schema parser is compiled against the serialization 1.8.x API, so + // the whole build resolves to that version to satisfy Gradle's consistent resolution between + // the main and androidTest classpaths. + constraints { + add("implementation", "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + } + // Compose BOM implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) @@ -117,12 +129,11 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.navigation.compose) // Hilt implementation(libs.hilt.android) ksp(libs.hilt.compiler) - implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) // DataStore implementation(libs.androidx.datastore.preferences) @@ -156,10 +167,12 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) } + tasks.register("jacocoTestReport") { dependsOn("testDebugUnitTest") reports { diff --git a/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json new file mode 100644 index 0000000..2510079 --- /dev/null +++ b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/4.json @@ -0,0 +1,171 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "9d831e611a5440fa11e057484fe587e8", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `alias` TEXT NOT NULL COLLATE NOCASE, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radius_meters` REAL NOT NULL, `notification_responsiveness_ms` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radius_meters", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "notificationResponsivenessMs", + "columnName": "notification_responsiveness_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_locations_alias", + "unique": true, + "columnNames": [ + "alias" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_locations_alias` ON `${TABLE_NAME}` (`alias`)" + }, + { + "name": "index_locations_latitude_longitude", + "unique": false, + "columnNames": [ + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_latitude_longitude` ON `${TABLE_NAME}` (`latitude`, `longitude`)" + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `location_id` TEXT NOT NULL, `message` TEXT NOT NULL, `transition_type` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_in_range` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transitionType", + "columnName": "transition_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInRange", + "columnName": "is_in_range", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_location_id` ON `${TABLE_NAME}` (`location_id`)" + }, + { + "name": "index_reminders_is_active_created_at", + "unique": false, + "columnNames": [ + "is_active", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_is_active_created_at` ON `${TABLE_NAME}` (`is_active`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d831e611a5440fa11e057484fe587e8')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json new file mode 100644 index 0000000..2864a9c --- /dev/null +++ b/app/schemas/dev.arrase.geotify.data.GeotifyDatabase/5.json @@ -0,0 +1,171 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "9d831e611a5440fa11e057484fe587e8", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `alias` TEXT NOT NULL COLLATE NOCASE, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radius_meters` REAL NOT NULL, `notification_responsiveness_ms` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "radiusMeters", + "columnName": "radius_meters", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "notificationResponsivenessMs", + "columnName": "notification_responsiveness_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_locations_alias", + "unique": true, + "columnNames": [ + "alias" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_locations_alias` ON `${TABLE_NAME}` (`alias`)" + }, + { + "name": "index_locations_latitude_longitude", + "unique": false, + "columnNames": [ + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_latitude_longitude` ON `${TABLE_NAME}` (`latitude`, `longitude`)" + } + ] + }, + { + "tableName": "reminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `location_id` TEXT NOT NULL, `message` TEXT NOT NULL, `transition_type` INTEGER NOT NULL, `is_active` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_in_range` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transitionType", + "columnName": "transition_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "is_active", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInRange", + "columnName": "is_in_range", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_reminders_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_location_id` ON `${TABLE_NAME}` (`location_id`)" + }, + { + "name": "index_reminders_is_active_created_at", + "unique": false, + "columnNames": [ + "is_active", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_is_active_created_at` ON `${TABLE_NAME}` (`is_active`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9d831e611a5440fa11e057484fe587e8')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt b/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt index d55ddbc..cf044e4 100644 --- a/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt +++ b/app/src/androidTest/java/dev/arrase/geotify/GeofenceRegistrationTest.kt @@ -1,35 +1,76 @@ package dev.arrase.geotify -import androidx.test.platform.app.InstrumentationRegistry +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.geofence.AndroidGeofenceManager import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith -import com.google.android.gms.location.Geofence +/** + * Exercises the real Play services geofencing client. Play services exposes no way to query the + * registered set, so these tests assert the contract the app depends on: registration is refused + * (not silently swallowed) when permissions are missing, and accepted otherwise. + */ @RunWith(AndroidJUnit4::class) class GeofenceRegistrationTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val manager = AndroidGeofenceManager(context, SettingsManager(context)) + + private val location = LocationEntity( + id = "instrumentation_test_location", + alias = "TestLocation", + latitude = 39.950914, + longitude = -0.062596, + radiusMeters = 100f + ) + @Test - fun testRegisterGeofence() = runBlocking { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val settingsManager = dev.arrase.geotify.data.SettingsManager(appContext) - val manager = AndroidGeofenceManager(appContext, settingsManager) - val location = LocationEntity( - id = "test_location_id", - alias = "TestLocation", - latitude = 39.950914, - longitude = -0.062596, - radiusMeters = 100f - ) + fun slidingWindow_isAcceptedByPlayServices_andCanBeCleared() = runBlocking { + assumeTrue("Location permission not granted", hasLocationPermission()) + try { - manager.registerGeofenceForLocation(location, Geofence.GEOFENCE_TRANSITION_ENTER) - println("Geofence registered successfully in test") - } catch (e: Exception) { - e.printStackTrace() - throw e + val registered = manager.registerSlidingWindowGeofences( + locations = mapOf(location to Geofence.GEOFENCE_TRANSITION_ENTER), + centerLat = location.latitude, + centerLon = location.longitude, + innerRadiusMeters = 500f + ) + assertTrue("GMS rejected the sliding window request", registered) + + // Must not throw when a window is already registered. + manager.removeAllGeofences() + } finally { + // Never leave a geofence behind: it consumes a slot of the 100-per-app budget. + runCatching { manager.removeAllGeofences() } } } + + @Test + fun registrationWithoutPermission_reportsFailureInsteadOfRegistering() = runBlocking { + assumeTrue("Test requires the permission to be absent", !hasLocationPermission()) + + assertFalse( + "Registration should be refused without permissions", + manager.registerMasterGeofence( + centerLat = location.latitude, + centerLon = location.longitude, + innerRadiusMeters = 500f + ) + ) + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED } diff --git a/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt b/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt new file mode 100644 index 0000000..3c8acf3 --- /dev/null +++ b/app/src/androidTest/java/dev/arrase/geotify/data/MigrationTest.kt @@ -0,0 +1,134 @@ +package dev.arrase.geotify.data + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Exercises the real SQL of every migration step against a database built from the exported + * schema, on a device. + * + * The alias-trimming step is covered exhaustively because it is easy to write a correlated + * subquery that silently matches nothing: the migration then completes, the app opens fine, and + * nothing is cleaned up at all. Only real SQLite can catch that. + */ +@RunWith(AndroidJUnit4::class) +class MigrationTest { + + @get:Rule + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + // Only used to locate the exported schemas under the test APK's assets, which Room + // writes under the database class name. The database file itself is named per test. + GeotifyDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migratesFromV1ToCurrent() { + helper.createDatabase(TEST_DB, 1).use { db -> + db.execSQL( + """ + INSERT INTO locations + (id, alias, latitude, longitude, radius_meters, notification_responsiveness_ms) + VALUES ('l1', 'Casa', 39.95, -0.06, 100.0, 0) + """.trimIndent() + ) + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + // runMigrationsAndValidate checks the schema; the row must also have survived the chain. + assertEquals(listOf("Casa"), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_trimsAliasesThatGainedWhitespace() { + helper.createDatabase(TEST_DB, 4).use { db -> + listOf( + "l1" to "Casa", + "l2" to "Consum ", + "l3" to "Mercadona ", + "l4" to "\tPipican\n", + "l5" to " Plaza " + ).forEach { (id, alias) -> db.insertLocation(id, alias) } + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals( + listOf("Casa", "Consum", "Mercadona", "Pipican", "Plaza"), + migrated.readAliases() + ) + migrated.close() + } + + @Test + fun v4ToV5_keepsCollidingAliasesSoTheUniqueIndexIsNotViolated() { + // The old duplicate check compared aliases without trimming, so both could exist. + helper.createDatabase(TEST_DB, 4).use { db -> + listOf("l1" to "Shop", "l2" to "Shop ", "l3" to "Cafe ") + .forEach { (id, alias) -> db.insertLocation(id, alias) } + } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + // 'Shop' vs 'Shop ' collide and are both left alone; the unrelated row is still fixed. + assertEquals(listOf("Shop", "Shop ", "Cafe"), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_doesNotBlankAnAllWhitespaceAlias() { + helper.createDatabase(TEST_DB, 4).use { db -> db.insertLocation("l1", " ") } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals(listOf(" "), migrated.readAliases()) + migrated.close() + } + + @Test + fun v4ToV5_leavesCleanAliasesUntouched() { + helper.createDatabase(TEST_DB, 4).use { db -> db.insertLocation("l1", "Mi Casa Bonita") } + + val migrated = helper.runMigrationsAndValidate( + TEST_DB, CURRENT_VERSION, validateDroppedTables = true, *Migrations.all() + ) + + assertEquals(listOf("Mi Casa Bonita"), migrated.readAliases()) + migrated.close() + } + + private fun androidx.sqlite.db.SupportSQLiteDatabase.insertLocation(id: String, alias: String) { + execSQL( + "INSERT INTO locations " + + "(id, alias, latitude, longitude, radius_meters, notification_responsiveness_ms) " + + "VALUES ('$id', '${alias.replace("'", "''")}', 39.95, -0.06, 100.0, 0)" + ) + } + + private fun androidx.sqlite.db.SupportSQLiteDatabase.readAliases(): List = + query("SELECT alias FROM locations ORDER BY rowid").use { cursor -> + buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } + } + + private companion object { + const val TEST_DB = "migration-test" + const val CURRENT_VERSION = 5 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4f798b..3ac5d90 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,21 +49,20 @@ - + - - - - + android:exported="false" /> - + + diff --git a/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt b/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt index 538bdf2..479ff5f 100644 --- a/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt +++ b/app/src/main/java/dev/arrase/geotify/GeotifyApplication.kt @@ -2,28 +2,20 @@ package dev.arrase.geotify import android.app.Application import androidx.appfunctions.service.AppFunctionConfiguration -import android.util.Log import dagger.hilt.android.HiltAndroidApp import dev.arrase.geotify.appfunction.GeotifyAppFunctions -import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.notification.NotificationHelper import javax.inject.Inject @HiltAndroidApp class GeotifyApplication : Application(), AppFunctionConfiguration.Provider { - @Inject - lateinit var geofenceOrchestrator: GeofenceOrchestrator - @Inject lateinit var geotifyAppFunctions: GeotifyAppFunctions override fun onCreate() { super.onCreate() - Log.i(TAG, "Initializing notification channels...") NotificationHelper.createNotificationChannels(this) - Log.i(TAG, "Enqueuing geofence recalculation...") - geofenceOrchestrator.triggerExpeditedRecalculation() } override val appFunctionConfiguration: AppFunctionConfiguration @@ -32,8 +24,4 @@ class GeotifyApplication : Application(), AppFunctionConfiguration.Provider { geotifyAppFunctions } .build() - - companion object { - private const val TAG = "GeotifyApp" - } } diff --git a/app/src/main/java/dev/arrase/geotify/MainActivity.kt b/app/src/main/java/dev/arrase/geotify/MainActivity.kt index 2b25ae7..6c5f31d 100644 --- a/app/src/main/java/dev/arrase/geotify/MainActivity.kt +++ b/app/src/main/java/dev/arrase/geotify/MainActivity.kt @@ -1,53 +1,66 @@ package dev.arrase.geotify +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import dev.arrase.geotify.data.ThemeSetting +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.permission.PermissionGate import dev.arrase.geotify.ui.MainViewModel import dev.arrase.geotify.ui.navigation.GeotifyNavHost import dev.arrase.geotify.ui.navigation.GeotifyTab import dev.arrase.geotify.ui.theme.GeotifyTheme +import dev.arrase.geotify.ui.theme.resolve +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var geofenceOrchestrator: GeofenceOrchestrator + private val viewModel: MainViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val initialTab = if (intent.getStringExtra(EXTRA_TAB) == TAB_LOCATIONS) { - GeotifyTab.Locations - } else { - GeotifyTab.Reminders + // Only on a user-initiated launch: a process started by a broadcast receiver must not + // spend battery re-registering geofences that the receiver itself is about to refresh. + if (savedInstanceState == null) { + geofenceOrchestrator.triggerExpeditedRecalculation() } + setContent { val appTheme by viewModel.appTheme.collectAsStateWithLifecycle() - val useDarkTheme = when (appTheme) { - ThemeSetting.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true - } - GeotifyTheme(darkTheme = useDarkTheme) { + GeotifyTheme(darkTheme = appTheme.resolve(isSystemInDarkTheme())) { PermissionGate { - GeotifyNavHost(initialTab = initialTab) + GeotifyNavHost(initialTab = requestedTab(intent)) } } } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + companion object { + /** Intent extra carrying a [GeotifyTab] name. */ const val EXTRA_TAB = "tab" - const val TAB_LOCATIONS = "locations" + + private fun requestedTab(intent: Intent?): GeotifyTab { + val requested = intent?.getStringExtra(EXTRA_TAB) ?: return GeotifyTab.Reminders + return GeotifyTab.entries.firstOrNull { it.name == requested } ?: GeotifyTab.Reminders + } } } diff --git a/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt b/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt index cf3ade7..bf9d9f4 100644 --- a/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt +++ b/app/src/main/java/dev/arrase/geotify/appfunction/GeotifyAppFunctions.kt @@ -5,9 +5,11 @@ import androidx.appfunctions.AppFunctionInvalidArgumentException import androidx.appfunctions.AppFunctionSerializable import androidx.appfunctions.service.AppFunction import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.DuplicateAliasException import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.entity.triggerTypeString +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import javax.inject.Inject @@ -70,6 +72,7 @@ data class SavedReminder( class GeotifyAppFunctions @Inject constructor( private val locationRepository: LocationRepository, private val reminderRepository: ReminderRepository, + private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider ) { @@ -97,10 +100,16 @@ class GeotifyAppFunctions @Inject constructor( if (location != null) { locationRepository.saveLocation(alias, location.latitude, location.longitude) + geofenceOrchestrator.triggerRecalculation() SaveLocationResult(alias, "Location successfully saved.") } else { SaveLocationResult(alias, "Failed to obtain GPS fix.") } + } catch (e: DuplicateAliasException) { + SaveLocationResult( + alias, + "Alias already in use. Choose a unique name or delete the existing one first." + ) } catch (e: Exception) { SaveLocationResult(alias, "Error: ${e.message}") } @@ -132,11 +141,12 @@ class GeotifyAppFunctions @Inject constructor( } val reminder = reminderRepository.createReminder(location, payloadMessage, transitionType) + geofenceOrchestrator.triggerRecalculation() return CreateReminderResult( reminderId = reminder.id, targetAlias = targetAlias, payloadMessage = payloadMessage, - triggerType = if (triggerOnArrival) "arrival" else "departure" + triggerType = reminder.triggerTypeString ) } @@ -165,8 +175,9 @@ class GeotifyAppFunctions @Inject constructor( alias: String ): DeleteResult { locationRepository.findLocationByAlias(alias) ?: throwAliasNotFound(alias) - locationRepository.deleteLocation(alias) - return DeleteResult(alias, deleted = true) + val deleted = locationRepository.deleteLocation(alias) + geofenceOrchestrator.triggerRecalculation() + return DeleteResult(alias, deleted = deleted) } /** @@ -211,8 +222,9 @@ class GeotifyAppFunctions @Inject constructor( ) } - reminderRepository.cancelReminder(matched.id) - return DeleteResult(targetAlias, deleted = true) + val deleted = reminderRepository.cancelReminder(matched.id) + geofenceOrchestrator.triggerRecalculation() + return DeleteResult(targetAlias, deleted = deleted) } /** diff --git a/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt b/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt index f882bf9..f54fde7 100644 --- a/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt +++ b/app/src/main/java/dev/arrase/geotify/data/GeotifyDatabase.kt @@ -9,11 +9,16 @@ import dev.arrase.geotify.data.entity.ReminderEntity @Database( entities = [LocationEntity::class, ReminderEntity::class], - version = 3, + version = 5, exportSchema = true ) abstract class GeotifyDatabase : RoomDatabase() { abstract fun locationDao(): LocationDao + abstract fun reminderDao(): ReminderDao -} + + companion object { + const val NAME = "geotify.db" + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt b/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt index 0adb42d..6895a4a 100644 --- a/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt +++ b/app/src/main/java/dev/arrase/geotify/data/LocationRepository.kt @@ -25,27 +25,32 @@ class LocationRepository @Inject constructor( radiusMeters: Float = 150f, notificationResponsivenessMs: Int = 0 ): LocationEntity = withContext(ioDispatcher) { - require(latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } - require(longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } - require(radiusMeters >= 50f) { "Geofence radius must be at least 50 meters" } - require(notificationResponsivenessMs >= 0) { "Notification responsiveness must be non-negative" } + val normalizedAlias = alias.trim() + require(normalizedAlias.isNotEmpty()) { "Alias must not be blank" } + validate(latitude, longitude, radiusMeters, notificationResponsivenessMs) val entity = LocationEntity( id = UUID.randomUUID().toString(), - alias = alias, + alias = normalizedAlias, latitude = latitude, longitude = longitude, radiusMeters = radiusMeters, notificationResponsivenessMs = notificationResponsivenessMs ) - locationDao.insert(entity) + if (locationDao.insert(entity) == -1L) { + throw DuplicateAliasException(normalizedAlias) + } entity } suspend fun updateLocation(location: LocationEntity) = withContext(ioDispatcher) { - require(location.latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } - require(location.longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } - require(location.radiusMeters >= 50f) { "Geofence radius must be at least 50 meters" } - locationDao.update(location) + require(location.alias.isNotBlank()) { "Alias must not be blank" } + validate( + location.latitude, + location.longitude, + location.radiusMeters, + location.notificationResponsivenessMs + ) + locationDao.update(location.copy(alias = location.alias.trim())) } suspend fun getAllLocations(): List = withContext(ioDispatcher) { @@ -60,11 +65,48 @@ class LocationRepository @Inject constructor( locationDao.findById(id) } + /** + * Bounding-box prefilter. May return candidates outside [minLat]..[maxLat] range when the + * longitude window straddles the antimeridian; callers must still check exact distance. + */ + suspend fun findLocationsInBoundingBox( + minLat: Double, + maxLat: Double, + minLon: Double, + maxLon: Double + ): List = withContext(ioDispatcher) { + locationDao.getLocationsInBoundingBox(minLat, maxLat, minLon, maxLon) + } + suspend fun getAllAliases(): List = withContext(ioDispatcher) { locationDao.getAllAliases() } - suspend fun deleteLocation(alias: String) = withContext(ioDispatcher) { - locationDao.deleteByAlias(alias) + /** @return `true` if a location was removed. */ + suspend fun deleteLocation(alias: String): Boolean = withContext(ioDispatcher) { + locationDao.deleteByAlias(alias) > 0 + } + + private fun validate( + latitude: Double, + longitude: Double, + radiusMeters: Float, + notificationResponsivenessMs: Int + ) { + require(latitude in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } + require(longitude in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } + require(radiusMeters >= MIN_RADIUS_METERS) { + "Geofence radius must be at least $MIN_RADIUS_METERS meters" + } + require(notificationResponsivenessMs >= 0) { "Notification responsiveness must be non-negative" } + } + + private companion object { + const val MIN_RADIUS_METERS = 50f } } + +/** Thrown when saving a location whose alias is already taken (case-insensitive). */ +class DuplicateAliasException(alias: String) : + IllegalArgumentException("Alias '$alias' already exists") + diff --git a/app/src/main/java/dev/arrase/geotify/data/Migrations.kt b/app/src/main/java/dev/arrase/geotify/data/Migrations.kt new file mode 100644 index 0000000..a51b0f4 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/data/Migrations.kt @@ -0,0 +1,86 @@ +package dev.arrase.geotify.data + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Schema history for [GeotifyDatabase]. + * + * Every step from the first released version is declared here so that upgrading never destroys + * user data. Adding a version means bumping [GeotifyDatabase.version] and appending the matching + * entry here. + */ +internal object Migrations { + + /** Ordered migration chain, from the first released schema to the current one. */ + fun all(): Array = arrayOf(V1_TO_V2, V2_TO_V3, V3_TO_V4, V4_TO_V5) + + /** v2: composite index backing the spatial-search bounding-box query. */ + private val V1_TO_V2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_locations_latitude_longitude " + + "ON locations (latitude, longitude)" + ) + } + } + + /** v3: `is_in_range`, tracking whether a reminder's geofence is currently monitored. */ + private val V2_TO_V3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE reminders ADD COLUMN is_in_range INTEGER NOT NULL DEFAULT 0" + ) + } + } + + /** v4: index covering the active-reminder filter and its `ORDER BY`. */ + private val V3_TO_V4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_reminders_is_active_created_at " + + "ON reminders (is_active, created_at)" + ) + } + } + + /** + * v5: data cleanup only, no schema change. + * + * Aliases used to be persisted exactly as typed, so rows created before the repository + * started trimming them can carry leading or trailing whitespace (e.g. `"Mercadona "`). + * The uniqueness index is `COLLATE NOCASE`, which ignores case but not surrounding spaces, so + * such a row could not be found by its own alias and blocked creating the properly-spaced one. + * + * A row is left untouched when trimming it would either create a collision with another row + * or leave the alias empty. The former UI compared aliases without trimming, so `"Shop"` and + * `"Shop "` could both exist, and trimming those would violate the unique index and abort the + * whole statement, leaving the app unable to open its database. Such rows keep their original + * value for the user to fix, rather than being merged or blanked. + */ + private val V4_TO_V5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + // SQLite's TRIM() drops spaces only, while the repository trims any whitespace via + // Kotlin's String.trim(), so the same character list is given explicitly to keep + // existing rows consistent with newly saved ones. + // + // `locations.alias` must stay qualified inside the subquery: an unqualified `alias` + // would bind to `other.alias`, making the guard always false and silently trimming + // nothing. + val outerTrim = "TRIM(locations.alias, ' ' || char(9) || char(10) || char(13))" + db.execSQL( + """ + UPDATE locations + SET alias = $outerTrim + WHERE locations.alias <> $outerTrim + AND $outerTrim <> '' + AND NOT EXISTS ( + SELECT 1 FROM locations other + WHERE other.id <> locations.id + AND other.alias = $outerTrim COLLATE NOCASE + ) + """.trimIndent() + ) + } + } +} diff --git a/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt b/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt index c454c35..cd47d66 100644 --- a/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt +++ b/app/src/main/java/dev/arrase/geotify/data/ReminderRepository.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.data +import com.google.android.gms.location.Geofence import dev.arrase.geotify.data.dao.ReminderDao import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.LocationReminderCount @@ -23,16 +24,19 @@ class ReminderRepository @Inject constructor( fun observeActiveReminderCounts(): Flow> = reminderDao.observeActiveReminderCounts() - suspend fun createReminder( location: LocationEntity, message: String, transitionType: Int ): ReminderEntity = withContext(ioDispatcher) { + require(message.isNotBlank()) { "Reminder message must not be blank" } + require(transitionType in SUPPORTED_TRANSITIONS) { + "Unsupported geofence transition type: $transitionType" + } val reminder = ReminderEntity( id = UUID.randomUUID().toString(), locationId = location.id, - message = message, + message = message.trim(), transitionType = transitionType, createdAt = System.currentTimeMillis() ) @@ -41,6 +45,10 @@ class ReminderRepository @Inject constructor( } suspend fun updateReminder(reminder: ReminderEntity) = withContext(ioDispatcher) { + require(reminder.message.isNotBlank()) { "Reminder message must not be blank" } + require(reminder.transitionType in SUPPORTED_TRANSITIONS) { + "Unsupported geofence transition type: ${reminder.transitionType}" + } reminderDao.update(reminder) } @@ -48,20 +56,28 @@ class ReminderRepository @Inject constructor( reminderDao.deactivate(reminderId) } - suspend fun cancelReminder(reminderId: String) = withContext(ioDispatcher) { - reminderDao.deleteById(reminderId) + /** @return `true` if a reminder was removed. */ + suspend fun cancelReminder(reminderId: String): Boolean = withContext(ioDispatcher) { + reminderDao.deleteById(reminderId) > 0 } - suspend fun getActiveReminders(): List = withContext(ioDispatcher) { reminderDao.getActiveReminders() } - suspend fun getActiveRemindersForLocation(locationId: String): List = withContext(ioDispatcher) { - reminderDao.getActiveByLocationId(locationId) - } + suspend fun getActiveRemindersForLocation(locationId: String): List = + withContext(ioDispatcher) { + reminderDao.getActiveByLocationId(locationId) + } suspend fun updateInRangeStatus(locationIds: List) = withContext(ioDispatcher) { reminderDao.updateInRangeStatus(locationIds) } + + private companion object { + val SUPPORTED_TRANSITIONS = setOf( + Geofence.GEOFENCE_TRANSITION_ENTER, + Geofence.GEOFENCE_TRANSITION_EXIT + ) + } } diff --git a/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt b/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt index 8b57edd..7e4c916 100644 --- a/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt +++ b/app/src/main/java/dev/arrase/geotify/data/SettingsManager.kt @@ -4,15 +4,17 @@ import android.content.Context import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import java.io.IOException @@ -30,8 +32,6 @@ class SettingsManager @Inject constructor( @param:ApplicationContext private val context: Context ) { - // ── Read Preferences ── - val appTheme: Flow = preference(KEY_APP_THEME, SettingsDefaults.APP_THEME.name) .map { name -> ThemeSetting.entries.find { it.name == name } ?: SettingsDefaults.APP_THEME } @@ -42,19 +42,37 @@ class SettingsManager @Inject constructor( val innerRadiusR: Flow = preference(KEY_INNER_RADIUS_R, SettingsDefaults.INNER_RADIUS_R) - val locationCacheTimeoutSecs: Flow = preference(KEY_LOCATION_CACHE_TIMEOUT_SECS, SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) - - val recalculationDebounceSecs: Flow = preference(KEY_DEBOUNCE_DELAY_SECS, SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) - - val masterGeofenceResponsivenessSecs: Flow = preference(KEY_MASTER_RESPONSIVENESS_SECS, SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) - - val poiGeofenceResponsivenessSecs: Flow = preference(KEY_POI_RESPONSIVENESS_SECS, SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) - - val lastRecalcLat: Flow = preference(KEY_LAST_RECALC_LAT, 0.0) - .map { if (it == 0.0) null else it } - - val lastRecalcLng: Flow = preference(KEY_LAST_RECALC_LNG, 0.0) - .map { if (it == 0.0) null else it } + val locationCacheTimeoutSecs: Flow = + preference(KEY_LOCATION_CACHE_TIMEOUT_SECS, SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) + + val recalculationDebounceSecs: Flow = + preference(KEY_DEBOUNCE_DELAY_SECS, SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) + + val masterGeofenceResponsivenessSecs: Flow = + preference(KEY_MASTER_RESPONSIVENESS_SECS, SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) + + val poiGeofenceResponsivenessSecs: Flow = + preference(KEY_POI_RESPONSIVENESS_SECS, SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) + + /** + * Centre of the most recent geofence recalculation, or `null` if none has run yet. + * Backed by an explicit presence flag so that valid coordinates such as (0.0, 0.0) + * are not mistaken for "unset". + */ + val lastRecalcLocation: Flow = context.dataStore.data + .catch { exception -> recoverFrom(exception, KEY_HAS_LAST_RECALC) } + .map { preferences -> + // The flag and both coordinates are written in one atomic `edit`, so when the flag + // is set the coordinates are guaranteed to be present. + if (preferences[KEY_HAS_LAST_RECALC] != true) { + null + } else { + LatLng( + latitude = checkNotNull(preferences[KEY_LAST_RECALC_LAT]), + longitude = checkNotNull(preferences[KEY_LAST_RECALC_LNG]) + ) + } + } // ── Write Preferences ── @@ -66,54 +84,68 @@ class SettingsManager @Inject constructor( suspend fun setInnerRadiusR(radius: Float) = setPreference(KEY_INNER_RADIUS_R, radius) - suspend fun setLocationCacheTimeoutSecs(secs: Int) = setPreference(KEY_LOCATION_CACHE_TIMEOUT_SECS, secs) + suspend fun setLocationCacheTimeoutSecs(secs: Int) = + setPreference(KEY_LOCATION_CACHE_TIMEOUT_SECS, secs) suspend fun setRecalculationDebounceSecs(secs: Int) = setPreference(KEY_DEBOUNCE_DELAY_SECS, secs) - suspend fun setMasterGeofenceResponsivenessSecs(secs: Int) = setPreference(KEY_MASTER_RESPONSIVENESS_SECS, secs) + suspend fun setMasterGeofenceResponsivenessSecs(secs: Int) = + setPreference(KEY_MASTER_RESPONSIVENESS_SECS, secs) - suspend fun setPoiGeofenceResponsivenessSecs(secs: Int) = setPreference(KEY_POI_RESPONSIVENESS_SECS, secs) + suspend fun setPoiGeofenceResponsivenessSecs(secs: Int) = + setPreference(KEY_POI_RESPONSIVENESS_SECS, secs) - suspend fun setLastRecalcLocation(lat: Double, lng: Double) { - setPreference(KEY_LAST_RECALC_LAT, lat) - setPreference(KEY_LAST_RECALC_LNG, lng) + /** Stores both coordinates in a single atomic transaction. */ + suspend fun setLastRecalcLocation(latitude: Double, longitude: Double) { + context.dataStore.edit { preferences -> + preferences[KEY_LAST_RECALC_LAT] = latitude + preferences[KEY_LAST_RECALC_LNG] = longitude + preferences[KEY_HAS_LAST_RECALC] = true + } } // ── Private Helpers ── private fun preference(key: Preferences.Key, default: T): Flow = context.dataStore.data - .catch { exception -> - if (exception is IOException) { - Log.e(TAG, "Error reading preference: $key", exception) - emit(emptyPreferences()) - } else { - throw exception - } - } + .catch { exception -> recoverFrom(exception, key) } .map { preferences -> preferences[key] ?: default } private suspend fun setPreference(key: Preferences.Key, value: T) { - try { - context.dataStore.edit { preferences -> - preferences[key] = value - } - } catch (e: Exception) { - Log.e(TAG, "Error writing preference: $key", e) + context.dataStore.edit { preferences -> preferences[key] = value } + } + + /** + * Swallows the corruption error thrown when the DataStore file cannot be parsed, so a single + * unreadable preference does not permanently break the app. Any other failure propagates. + */ + private suspend fun FlowCollector.recoverFrom( + exception: Throwable, + key: Preferences.Key<*> + ) { + if (exception is IOException) { + Log.e(TAG, "Error reading preference: $key", exception) + emit(emptyPreferences()) + } else { + throw exception } } - companion object { - private const val TAG = "SettingsManager" - private val KEY_APP_THEME = stringPreferencesKey("app_theme") - private val KEY_MAP_THEME = stringPreferencesKey("map_theme") - private val KEY_OUTER_RADIUS_N = floatPreferencesKey("outer_radius_n_km") - private val KEY_INNER_RADIUS_R = floatPreferencesKey("inner_radius_r_km") - private val KEY_LOCATION_CACHE_TIMEOUT_SECS = intPreferencesKey("location_cache_timeout_secs") - private val KEY_DEBOUNCE_DELAY_SECS = intPreferencesKey("debounce_delay_secs") - private val KEY_MASTER_RESPONSIVENESS_SECS = intPreferencesKey("master_responsiveness_secs") - private val KEY_POI_RESPONSIVENESS_SECS = intPreferencesKey("poi_responsiveness_secs") - private val KEY_LAST_RECALC_LAT = doublePreferencesKey("last_recalc_lat") - private val KEY_LAST_RECALC_LNG = doublePreferencesKey("last_recalc_lng") + private companion object { + const val TAG = "SettingsManager" + val KEY_APP_THEME = stringPreferencesKey("app_theme") + val KEY_MAP_THEME = stringPreferencesKey("map_theme") + val KEY_OUTER_RADIUS_N = floatPreferencesKey("outer_radius_n_km") + val KEY_INNER_RADIUS_R = floatPreferencesKey("inner_radius_r_km") + val KEY_LOCATION_CACHE_TIMEOUT_SECS = intPreferencesKey("location_cache_timeout_secs") + val KEY_DEBOUNCE_DELAY_SECS = intPreferencesKey("debounce_delay_secs") + val KEY_MASTER_RESPONSIVENESS_SECS = intPreferencesKey("master_responsiveness_secs") + val KEY_POI_RESPONSIVENESS_SECS = intPreferencesKey("poi_responsiveness_secs") + val KEY_LAST_RECALC_LAT = doublePreferencesKey("last_recalc_lat") + val KEY_LAST_RECALC_LNG = doublePreferencesKey("last_recalc_lng") + val KEY_HAS_LAST_RECALC = booleanPreferencesKey("has_last_recalc") } } + +/** Immutable latitude/longitude pair. */ +data class LatLng(val latitude: Double, val longitude: Double) diff --git a/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt b/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt index 262f03b..6c9ca10 100644 --- a/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt +++ b/app/src/main/java/dev/arrase/geotify/data/dao/LocationDao.kt @@ -26,8 +26,9 @@ interface LocationDao { @Query("SELECT alias FROM locations ORDER BY alias ASC") suspend fun getAllAliases(): List - @Insert(onConflict = OnConflictStrategy.ABORT) - suspend fun insert(location: LocationEntity) + /** @return the new row id, or `-1` if the alias already exists. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(location: LocationEntity): Long @Update suspend fun update(location: LocationEntity) @@ -35,13 +36,21 @@ interface LocationDao { @Query("DELETE FROM locations WHERE alias = :alias COLLATE NOCASE") suspend fun deleteByAlias(alias: String): Int + /** + * Bounding-box prefilter for [dev.arrase.geotify.domain.SpatialSearchUseCase]. + * + * The longitude window is tested three times — as given, shifted by -360 and by +360 — so that a + * window straddling the antimeridian (e.g. 174..184) still matches points stored on the western + * side (e.g. -179). Results may be a superset of the true radius; callers must still apply an exact + * distance check. + */ @Query(""" - SELECT * FROM locations - WHERE latitude BETWEEN :minLat AND :maxLat + SELECT * FROM locations + WHERE latitude BETWEEN :minLat AND :maxLat AND ( - (:minLon <= :maxLon AND longitude BETWEEN :minLon AND :maxLon) - OR - (:minLon > :maxLon AND (longitude >= :minLon OR longitude <= :maxLon)) + (longitude BETWEEN :minLon AND :maxLon) + OR (longitude BETWEEN :minLon - 360.0 AND :maxLon - 360.0) + OR (longitude BETWEEN :minLon + 360.0 AND :maxLon + 360.0) ) """) suspend fun getLocationsInBoundingBox(minLat: Double, maxLat: Double, minLon: Double, maxLon: Double): List diff --git a/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt b/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt index b87e903..dbb517a 100644 --- a/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt +++ b/app/src/main/java/dev/arrase/geotify/data/dao/ReminderDao.kt @@ -16,28 +16,15 @@ interface ReminderDao { @Query("SELECT location_id, COUNT(*) as count FROM reminders WHERE is_active = 1 GROUP BY location_id") fun observeActiveReminderCounts(): Flow> - @Query("SELECT COUNT(DISTINCT location_id) FROM reminders WHERE is_active = 1") - suspend fun getActiveGeofenceCount(): Int - - - - @Query( - """ - SELECT r.* FROM reminders r - INNER JOIN locations l ON r.location_id = l.id - ORDER BY r.is_active DESC, r.created_at DESC - """ - ) + @Query("SELECT * FROM reminders ORDER BY is_active DESC, created_at DESC") fun observeAll(): Flow> @Query("SELECT * FROM reminders WHERE is_active = 1") suspend fun getActiveReminders(): List - @Query("SELECT * FROM reminders WHERE location_id = :locationId AND is_active = 1") suspend fun getActiveByLocationId(locationId: String): List - @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(reminder: ReminderEntity) @@ -50,18 +37,17 @@ interface ReminderDao { @Query("DELETE FROM reminders WHERE id = :id") suspend fun deleteById(id: String): Int - - @Query("UPDATE reminders SET is_in_range = 0") - suspend fun clearAllInRange() - - @Query("UPDATE reminders SET is_in_range = 1 WHERE location_id IN (:locationIds) AND is_active = 1") - suspend fun setInRangeForLocations(locationIds: List) - @Transaction suspend fun updateInRangeStatus(locationIds: List) { clearAllInRange() if (locationIds.isNotEmpty()) { - setInRangeForLocations(locationIds) + setInRangeForLocationIds(locationIds) } } + + @Query("UPDATE reminders SET is_in_range = 0") + suspend fun clearAllInRange() + + @Query("UPDATE reminders SET is_in_range = 1 WHERE location_id IN (:locationIds) AND is_active = 1") + suspend fun setInRangeForLocationIds(locationIds: List) } diff --git a/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt b/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt index f54f855..4c8ead3 100644 --- a/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt +++ b/app/src/main/java/dev/arrase/geotify/data/entity/ReminderEntity.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import com.google.android.gms.location.Geofence @Entity( tableName = "reminders", @@ -16,7 +17,7 @@ import androidx.room.PrimaryKey onDelete = ForeignKey.CASCADE ) ], - indices = [Index(value = ["location_id"])] + indices = [Index(value = ["location_id"]), Index(value = ["is_active", "created_at"])] ) data class ReminderEntity( @PrimaryKey @@ -35,16 +36,29 @@ data class ReminderEntity( ) val ReminderEntity.isArrival: Boolean - get() = transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_ENTER + get() = transitionType == Geofence.GEOFENCE_TRANSITION_ENTER val ReminderEntity.isDeparture: Boolean - get() = transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_EXIT + get() = transitionType == Geofence.GEOFENCE_TRANSITION_EXIT +/** Stable, human-readable label for [transitionType], used by App Functions responses. */ val ReminderEntity.triggerTypeString: String - get() = if (isArrival) "arrival" else "departure" + get() = when (transitionType) { + Geofence.GEOFENCE_TRANSITION_ENTER -> "arrival" + Geofence.GEOFENCE_TRANSITION_EXIT -> "departure" + else -> "unknown" + } + +/** + * Stable Android notification id for this reminder. + * + * Notification ids double as `PendingIntent` request codes, so two reminders sharing an id would + * make the second notification overwrite the first one's intent extras. Reminders are created one + * at a time by the user, so a millisecond timestamp is unique in practice and cheap to derive. + */ +fun ReminderEntity.notificationId(): Int = createdAt.toInt() xor id.hashCode() data class LocationReminderCount( @ColumnInfo(name = "location_id") val locationId: String, val count: Int ) - diff --git a/app/src/main/java/dev/arrase/geotify/di/AppModule.kt b/app/src/main/java/dev/arrase/geotify/di/AppModule.kt index 2705ded..24f5881 100644 --- a/app/src/main/java/dev/arrase/geotify/di/AppModule.kt +++ b/app/src/main/java/dev/arrase/geotify/di/AppModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.arrase.geotify.data.GeotifyDatabase +import dev.arrase.geotify.data.Migrations import dev.arrase.geotify.data.dao.LocationDao import dev.arrase.geotify.data.dao.ReminderDao import kotlinx.coroutines.CoroutineDispatcher @@ -25,25 +26,15 @@ object AppModule { @Provides @Singleton - @MainDispatcher - fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main - - @Provides - @Singleton - @DefaultDispatcher - fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default - - @Provides - @Singleton - fun provideDatabase(@ApplicationContext context: Context): GeotifyDatabase { - return Room.databaseBuilder( + fun provideDatabase(@ApplicationContext context: Context): GeotifyDatabase = + Room.databaseBuilder( context.applicationContext, GeotifyDatabase::class.java, - "geotify.db" + GeotifyDatabase.NAME ) - .fallbackToDestructiveMigration(dropAllTables = true) + // Every schema step is declared explicitly, so user data is never dropped on upgrade. + .addMigrations(*Migrations.all()) .build() - } @Provides fun provideLocationDao(database: GeotifyDatabase): LocationDao = database.locationDao() diff --git a/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt b/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt index c102321..d122489 100644 --- a/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt +++ b/app/src/main/java/dev/arrase/geotify/di/BindingsModule.kt @@ -8,7 +8,6 @@ import dev.arrase.geotify.geofence.AndroidGeofenceManager import dev.arrase.geotify.geofence.GeofenceManager import dev.arrase.geotify.location.DefaultLocationProvider import dev.arrase.geotify.location.LocationProvider -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -16,10 +15,8 @@ import javax.inject.Singleton interface BindingsModule { @Binds - @Singleton fun bindGeofenceManager(impl: AndroidGeofenceManager): GeofenceManager @Binds - @Singleton fun bindLocationProvider(impl: DefaultLocationProvider): LocationProvider } diff --git a/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt b/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt index a58f43c..858be94 100644 --- a/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt +++ b/app/src/main/java/dev/arrase/geotify/di/CoroutineDispatchers.kt @@ -2,14 +2,7 @@ package dev.arrase.geotify.di import javax.inject.Qualifier +/** Marks the dispatcher used for disk and database work. */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class MainDispatcher - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class DefaultDispatcher diff --git a/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt b/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt index f49bda2..d4c83b8 100644 --- a/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt +++ b/app/src/main/java/dev/arrase/geotify/domain/SpatialSearchUseCase.kt @@ -1,61 +1,77 @@ package dev.arrase.geotify.domain import android.location.Location -import dev.arrase.geotify.data.dao.LocationDao +import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.di.IoDispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext import javax.inject.Inject import kotlin.math.cos +/** + * Finds the locations closest to a centre point, closest first, capped at + * [MAX_POI_GEOFENCES]. Candidates are pre-filtered with a bounding box and then + * filtered by exact great-circle distance. + */ class SpatialSearchUseCase @Inject constructor( - private val locationDao: LocationDao + private val locationRepository: LocationRepository, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher ) { - suspend fun execute( + + suspend operator fun invoke( centerLat: Double, centerLon: Double, - radiusN: Float - ): List { - val radiusInMeters = radiusN * 1000.0 - val latDegreesChange = radiusInMeters / 111320.0 - - val latRad = Math.toRadians(centerLat) - val cosLat = cos(latRad) - val lonDegreesChange = if (cosLat > 0.0) { - radiusInMeters / (111320.0 * cosLat) - } else { - 360.0 - } - - val minLat = centerLat - latDegreesChange - val maxLat = centerLat + latDegreesChange - val minLon = centerLon - lonDegreesChange - val maxLon = centerLon + lonDegreesChange - - val candidates = locationDao.getLocationsInBoundingBox(minLat, maxLat, minLon, maxLon) - - val resultsWithDistance = mutableListOf>() - val resultsBuffer = FloatArray(1) - - for (candidate in candidates) { - Location.distanceBetween( - centerLat, - centerLon, - candidate.latitude, - candidate.longitude, - resultsBuffer - ) - val dist = resultsBuffer[0] - if (dist <= radiusInMeters) { - resultsWithDistance.add(Pair(candidate, dist)) - } - } + radiusKm: Float + ): List = withContext(ioDispatcher) { + require(centerLat in -90.0..90.0) { "Latitude must be between -90.0 and 90.0" } + require(centerLon in -180.0..180.0) { "Longitude must be between -180.0 and 180.0" } + require(radiusKm >= 0f) { "Radius must be non-negative" } + + val radiusMeters = radiusKm * METERS_PER_KM + val latDelta = radiusMeters / METERS_PER_DEGREE + + // A degree of longitude shrinks towards the poles, where it reaches zero. Validation + // above guarantees `centerLat` is within [-90, 90], so `cosLat` is never negative. + val cosLat = cos(Math.toRadians(centerLat)) + val lonDelta = + if (cosLat < COS_LAT_EPSILON) 360.0 + else radiusMeters / (METERS_PER_DEGREE * cosLat) + + val candidates = locationRepository.findLocationsInBoundingBox( + minLat = centerLat - latDelta, + maxLat = centerLat + latDelta, + minLon = centerLon - lonDelta, + maxLon = centerLon + lonDelta + ) - return resultsWithDistance - .sortedBy { it.second } - .map { it.first } + val distanceBuffer = FloatArray(1) + candidates + .map { candidate -> + Location.distanceBetween( + centerLat, + centerLon, + candidate.latitude, + candidate.longitude, + distanceBuffer + ) + candidate to distanceBuffer[0] + } + .filter { (_, distance) -> distance <= radiusMeters } + .sortedBy { (_, distance) -> distance } + .map { (candidate, _) -> candidate } .take(MAX_POI_GEOFENCES) } companion object { + private const val METERS_PER_KM = 1000.0 + + /** Approximate meters in one degree of latitude. */ + private const val METERS_PER_DEGREE = 111_320.0 + + /** Below this |cos(lat)| the longitude span covers the whole globe. */ + private const val COS_LAT_EPSILON = 1e-6 + /** GMS allows a maximum of 100 geofences per app; 1 is reserved for the master geofence. */ const val MAX_POI_GEOFENCES = 99 } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt b/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt index 66a7d9b..67f971f 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/AndroidGeofenceManager.kt @@ -1,7 +1,7 @@ package dev.arrase.geotify.geofence -import android.annotation.SuppressLint import android.Manifest +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -13,15 +13,14 @@ import com.google.android.gms.location.Geofence import com.google.android.gms.location.GeofencingClient import com.google.android.gms.location.GeofencingRequest import com.google.android.gms.location.LocationServices +import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.entity.LocationEntity import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton -import dev.arrase.geotify.data.SettingsManager -import kotlinx.coroutines.flow.first - @Singleton class AndroidGeofenceManager @Inject constructor( @param:ApplicationContext private val context: Context, @@ -38,7 +37,7 @@ class AndroidGeofenceManager @Inject constructor( */ private val geofencePendingIntent: PendingIntent by lazy { val intent = Intent(context, GeofenceBroadcastReceiver::class.java).apply { - action = "dev.arrase.geotify.ACTION_RECEIVE_GEOFENCE" + action = ACTION_RECEIVE_GEOFENCE } PendingIntent.getBroadcast( context, 0, intent, @@ -46,151 +45,107 @@ class AndroidGeofenceManager @Inject constructor( ) } - @SuppressLint("MissingPermission") - override suspend fun registerGeofenceForLocation(location: LocationEntity, transitionTypes: Int) { - Log.i("GeofenceManager", "registerGeofenceForLocation: alias=${location.alias}, id=${location.id}, transitionTypes=$transitionTypes") - if (!hasRequiredLocationPermissions()) return - - val defaultPoiResponsivenessMs = settingsManager.poiGeofenceResponsivenessSecs.first() * 1000 - val geofence = Geofence.Builder() - .setRequestId(location.id) - .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(transitionTypes) - .setNotificationResponsiveness(maxOf(defaultPoiResponsivenessMs, location.notificationResponsivenessMs)) - .build() - - val request = GeofencingRequest.Builder() - .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - .addGeofence(geofence) - .build() - - Log.i("GeofenceManager", "Calling addGeofences for location ${location.alias} with radius ${location.radiusMeters}...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered geofence in GMS for: ${location.alias}") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register geofence in GMS for: ${location.alias}", e) - throw e - } - } - - override suspend fun removeGeofences(requestIds: List) { - if (requestIds.isEmpty()) return - geofencingClient.removeGeofences(requestIds).await() - } - override suspend fun removeAllGeofences() { - Log.i("GeofenceManager", "removeAllGeofences: Purging all geofences registered with pending intent...") - try { - geofencingClient.removeGeofences(geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully removed all geofences") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to remove all geofences", e) - throw e - } + Log.i(TAG, "Purging all geofences registered with pending intent...") + geofencingClient.removeGeofences(geofencePendingIntent).await() + Log.i(TAG, "Successfully removed all geofences") } - @SuppressLint("MissingPermission") override suspend fun registerSlidingWindowGeofences( locations: Map, centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) { - Log.i("GeofenceManager", "registerSlidingWindowGeofences: centerLat=$centerLat, centerLon=$centerLon, innerRadiusMeters=$innerRadiusMeters, locationsCount=${locations.size}") - if (!hasRequiredLocationPermissions()) return - - val requestBuilder = GeofencingRequest.Builder() + ): Boolean { + val request = GeofencingRequest.Builder() .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - - // 1. Create and add Master Geofence - val masterResponsivenessMs = settingsManager.masterGeofenceResponsivenessSecs.first() * 1000 - val masterGeofence = Geofence.Builder() - .setRequestId("MASTER_GEOFENCE_TRIGGER") - .setCircularRegion(centerLat, centerLon, innerRadiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) - .setNotificationResponsiveness(masterResponsivenessMs) - .build() - requestBuilder.addGeofence(masterGeofence) - - // 2. Create and add POIs geofences - val defaultPoiResponsivenessMs = settingsManager.poiGeofenceResponsivenessSecs.first() * 1000 - for ((location, transitionTypes) in locations) { - val geofence = Geofence.Builder() - .setRequestId(location.id) - .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(transitionTypes) - .setNotificationResponsiveness(maxOf(defaultPoiResponsivenessMs, location.notificationResponsivenessMs)) // batching - .build() - requestBuilder.addGeofence(geofence) - } - - val request = requestBuilder.build() - - Log.i("GeofenceManager", "Calling addGeofences for sliding window...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered sliding window geofences in GMS") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register sliding window geofences in GMS", e) - throw e + .addGeofence(buildMasterGeofence(centerLat, centerLon, innerRadiusMeters)) + locations.forEach { (location, transitionTypes) -> + request.addGeofence(buildPoiGeofence(location, transitionTypes)) } + return submit(request.build(), "sliding window (${locations.size} POIs + master)") } - @SuppressLint("MissingPermission") override suspend fun registerMasterGeofence( centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) { - Log.i("GeofenceManager", "registerMasterGeofence: centerLat=$centerLat, centerLon=$centerLon, innerRadiusMeters=$innerRadiusMeters") - if (!hasRequiredLocationPermissions()) return - - val masterResponsivenessMs = settingsManager.masterGeofenceResponsivenessSecs.first() * 1000 - val masterGeofence = Geofence.Builder() - .setRequestId("MASTER_GEOFENCE_TRIGGER") - .setCircularRegion(centerLat, centerLon, innerRadiusMeters) - .setExpirationDuration(Geofence.NEVER_EXPIRE) - .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) - .setNotificationResponsiveness(masterResponsivenessMs) + ): Boolean { + // No initial trigger: the master geofence only listens for exits, and GMS ignores an + // initial trigger whose transition type is not in the geofence's own transition mask. + val request = GeofencingRequest.Builder() + .addGeofence(buildMasterGeofence(centerLat, centerLon, innerRadiusMeters)) .build() + return submit(request, "master only") + } - val request = GeofencingRequest.Builder() - .setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER) - .addGeofence(masterGeofence) + private suspend fun buildMasterGeofence( + centerLat: Double, + centerLon: Double, + innerRadiusMeters: Float + ): Geofence = Geofence.Builder() + .setRequestId(GeofenceManager.MASTER_REQUEST_ID) + .setCircularRegion(centerLat, centerLon, innerRadiusMeters) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_EXIT) + .setNotificationResponsiveness( + settingsManager.masterGeofenceResponsivenessSecs.first() * MILLIS_PER_SECOND + ) + .build() + + private suspend fun buildPoiGeofence( + location: LocationEntity, + transitionTypes: Int + ): Geofence { + val defaultResponsiveness = + settingsManager.poiGeofenceResponsivenessSecs.first() * MILLIS_PER_SECOND + return Geofence.Builder() + .setRequestId(location.id) + .setCircularRegion(location.latitude, location.longitude, location.radiusMeters) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(transitionTypes) + // GMS requires a non-zero responsiveness; fall back to the app-wide default. + .setNotificationResponsiveness( + maxOf(defaultResponsiveness, location.notificationResponsivenessMs) + ) .build() + } - Log.i("GeofenceManager", "Calling addGeofences for master geofence only...") - try { - geofencingClient.addGeofences(request, geofencePendingIntent).await() - Log.i("GeofenceManager", "Successfully registered master geofence only in GMS") - } catch (e: Exception) { - Log.e("GeofenceManager", "Failed to register master geofence in GMS", e) - throw e + /** @return `true` if GMS now holds this window, `false` if permissions prevented it. */ + @SuppressLint("MissingPermission") + private suspend fun submit(request: GeofencingRequest, description: String): Boolean { + if (!hasLocationPermissions()) { + Log.w(TAG, "Skipping registration of $description geofences: permissions not granted") + return false } + Log.i(TAG, "Registering $description geofences with GMS...") + geofencingClient.addGeofences(request, geofencePendingIntent).await() + Log.i(TAG, "Successfully registered $description geofences") + return true } - private fun hasRequiredLocationPermissions(): Boolean { - val fineLocationPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) - val backgroundLocationPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + /** + * Geofencing needs a precise-enough fix, so `ACCESS_FINE_LOCATION` is required. Play services + * only grants it when the user chose precise location, not approximate. + */ + private fun hasLocationPermissions(): Boolean { + val fineLocation = ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + val backgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContextCompat.checkSelfPermission( + context, Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED } else { - PackageManager.PERMISSION_GRANTED + true } - if (fineLocationPermission != PackageManager.PERMISSION_GRANTED || - backgroundLocationPermission != PackageManager.PERMISSION_GRANTED - ) { - Log.w("GeofenceManager", MSG_PERMISSIONS_NOT_GRANTED) - return false - } - return true + return fineLocation && backgroundLocation } - companion object { - private const val MSG_PERMISSIONS_NOT_GRANTED = "Aborting registration: Permissions not granted!" + private companion object { + const val TAG = "GeotifyGeofence" + const val ACTION_RECEIVE_GEOFENCE = "dev.arrase.geotify.ACTION_RECEIVE_GEOFENCE" + const val MILLIS_PER_SECOND = 1_000 } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt b/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt index c7942c1..8e9c2e8 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/BootCompletedReceiver.kt @@ -12,6 +12,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +/** + * Re-registers the geofence window after the device boots or the app is updated, since Play + * services drops every geofence in both cases. + */ class BootCompletedReceiver : BroadcastReceiver() { @EntryPoint @@ -21,23 +25,23 @@ class BootCompletedReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + if (intent.action !in RELEVANT_ACTIONS) return if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED ) { - Log.w(TAG, "Location permission not granted. Skipping geofence re-registration after boot.") + Log.w(TAG, "Location permission not granted. Skipping geofence re-registration.") return } - Log.i(TAG, "Boot completed. Re-registering geofences...") - val entryPoint = EntryPointAccessors.fromApplication( - context, BootEntryPoint::class.java - ) - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + Log.i(TAG, "Triggering geofence re-registration after ${intent.action}") + EntryPointAccessors.fromApplication(context, BootEntryPoint::class.java) + .geofenceOrchestrator() + .triggerExpeditedRecalculation() } - companion object { - private const val TAG = "BootCompletedReceiver" + private companion object { + const val TAG = "GeotifyBootReceiver" + val RELEVANT_ACTIONS = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt index 1e32d92..0663903 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceBroadcastReceiver.kt @@ -12,9 +12,11 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository +import dev.arrase.geotify.data.entity.notificationId import dev.arrase.geotify.notification.NotificationHelper import dev.arrase.geotify.util.goAsyncCoroutine +/** Receives geofence transitions from Google Play services. */ class GeofenceBroadcastReceiver : BroadcastReceiver() { @EntryPoint @@ -26,93 +28,90 @@ class GeofenceBroadcastReceiver : BroadcastReceiver() { } override fun onReceive(context: Context, intent: Intent) { - Log.d(TAG, "onReceive triggered with intent: $intent") - val geofencingEvent = GeofencingEvent.fromIntent(intent) - if (geofencingEvent == null) { - Log.w(TAG, "GeofencingEvent is null in received intent") + val event = GeofencingEvent.fromIntent(intent) + if (event == null) { + Log.w(TAG, "Received an intent without a GeofencingEvent") return } - if (geofencingEvent.hasError()) { - Log.e(TAG, "Geofencing error code: ${geofencingEvent.errorCode}") + val entryPoint = EntryPointAccessors.fromApplication(context, ReceiverEntryPoint::class.java) + val orchestrator = entryPoint.geofenceOrchestrator() + + if (event.hasError()) { + // Errors such as GEOFENCE_TOO_MANY_GEOFENCES or GEOFENCE_NOT_REGISTERED leave the + // monitored set inconsistent; only a recalculation can recover from them. + Log.e(TAG, "Geofencing error code: ${event.errorCode}. Scheduling recalculation.") + orchestrator.triggerExpeditedRecalculation() return } - val triggeringGeofences = geofencingEvent.triggeringGeofences + val triggeringGeofences = event.triggeringGeofences if (triggeringGeofences.isNullOrEmpty()) { - Log.w(TAG, "No triggering geofences found in event") + Log.w(TAG, "GeofencingEvent contained no triggering geofences") return } - val transitionType = geofencingEvent.geofenceTransition - Log.d(TAG, "Triggered geofences count: ${triggeringGeofences.size}, transitionType: $transitionType") - - val entryPoint = EntryPointAccessors.fromApplication( - context, ReceiverEntryPoint::class.java - ) + val transitionType = event.geofenceTransition + Log.d(TAG, "${triggeringGeofences.size} geofence(s) triggered, transition=$transitionType") - val hasMasterExit = triggeringGeofences.any { it.requestId == "MASTER_GEOFENCE_TRIGGER" } && - transitionType == Geofence.GEOFENCE_TRANSITION_EXIT - if (hasMasterExit) { - Log.i(TAG, "Master geofence exit triggered. Enqueuing expedited recalculation...") - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + val masterExited = transitionType == Geofence.GEOFENCE_TRANSITION_EXIT && + triggeringGeofences.any { it.requestId == GeofenceManager.MASTER_REQUEST_ID } + if (masterExited) { + Log.i(TAG, "Left the recalculation area. Scheduling expedited recalculation.") + orchestrator.triggerExpeditedRecalculation() } - val poiGeofences = triggeringGeofences.filter { it.requestId != "MASTER_GEOFENCE_TRIGGER" } + val poiGeofences = triggeringGeofences + .filter { it.requestId != GeofenceManager.MASTER_REQUEST_ID } if (poiGeofences.isEmpty()) return goAsyncCoroutine { - try { - val locationRepo = entryPoint.locationRepository() - val reminderRepo = entryPoint.reminderRepository() - processPoiGeofences(context, locationRepo, reminderRepo, poiGeofences, transitionType) - } catch (e: Exception) { - Log.e(TAG, "Error processing geofence event", e) - } + runCatching { + processPoiTransitions( + context = context, + locationRepository = entryPoint.locationRepository(), + reminderRepository = entryPoint.reminderRepository(), + poiGeofences = poiGeofences, + transitionType = transitionType + ) + }.onFailure { Log.e(TAG, "Failed to process geofence event", it) } - // Re-evaluate geofences after deactivating triggered reminders. - // If no active reminders remain, this will purge all geofences to save battery. - entryPoint.geofenceOrchestrator().triggerExpeditedRecalculation() + // Re-evaluate the window now that the triggered reminders are no longer active. + orchestrator.triggerExpeditedRecalculation() } } - private suspend fun processPoiGeofences( + private suspend fun processPoiTransitions( context: Context, - locationRepo: LocationRepository, - reminderRepo: ReminderRepository, + locationRepository: LocationRepository, + reminderRepository: ReminderRepository, poiGeofences: List, transitionType: Int ) { - for (geofence in poiGeofences) { + poiGeofences.forEach { geofence -> val locationId = geofence.requestId - val location = locationRepo.findLocationById(locationId) + val location = locationRepository.findLocationById(locationId) if (location == null) { - Log.d(TAG, "Location not found in database for geofence ID: $locationId") - continue + Log.d(TAG, "No location stored for geofence $locationId; ignoring") + return@forEach } - Log.d(TAG, "Processing geofence for location: ${location.alias} (ID: $locationId)") - - val activeReminders = reminderRepo.getActiveRemindersForLocation(locationId) - Log.d(TAG, "Found ${activeReminders.size} active reminders for location ID: $locationId") - - val matchingReminders = activeReminders.filter { it.transitionType == transitionType } - Log.d(TAG, "Found ${matchingReminders.size} matching reminders for transitionType: $transitionType") - for (reminder in matchingReminders) { - Log.d(TAG, "Deactivating and showing notification for reminder ID: ${reminder.id}") - reminderRepo.deactivateReminder(reminder.id) + val due = reminderRepository.getActiveRemindersForLocation(locationId) + .filter { it.transitionType == transitionType } + due.forEach { reminder -> NotificationHelper.showGeofenceNotification( - context, - reminder.id.hashCode(), - location.alias, - reminder.message + context = context, + notificationId = reminder.notificationId(), + alias = location.alias, + message = reminder.message ) + reminderRepository.deactivateReminder(reminder.id) } } } - companion object { - private const val TAG = "GeofenceBroadcastReceiver" + private companion object { + const val TAG = "GeotifyGeofenceReceiver" } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt index 1284d20..da3c611 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceManager.kt @@ -2,19 +2,38 @@ package dev.arrase.geotify.geofence import dev.arrase.geotify.data.entity.LocationEntity +/** Registers and clears the geofences monitored by Google Play services. */ interface GeofenceManager { - suspend fun registerGeofenceForLocation(location: LocationEntity, transitionTypes: Int) - suspend fun removeGeofences(requestIds: List) + + /** Removes every geofence previously registered by this app. */ suspend fun removeAllGeofences() + + /** + * Replaces the monitored set with a master geofence plus one geofence per entry in [locations], + * keyed by location and mapped to its GMS transition-type bitmask. + * + * @return `true` if GMS accepted the new window, `false` if a required permission is missing. + */ suspend fun registerSlidingWindowGeofences( locations: Map, centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) + ): Boolean + + /** + * Registers only the master geofence, used when no POI is currently in range. + * + * @return `true` if GMS accepted it, `false` if a required permission is missing. + */ suspend fun registerMasterGeofence( centerLat: Double, centerLon: Double, innerRadiusMeters: Float - ) + ): Boolean + + companion object { + /** Request id of the geofence centred on the user, used to detect leaving the inner radius. */ + const val MASTER_REQUEST_ID = "MASTER_GEOFENCE_TRIGGER" + } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt index c1325cb..23656fd 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceOrchestrator.kt @@ -14,6 +14,15 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +/** + * Schedules [GeofenceRecalculationWorker] runs. + * + * The two paths use separate unique work names so they cannot cancel one another, and the policy + * differs by intent: a recalculation replaces the whole GMS geofence set, so a debounced run that is + * already pending must be *replaced* to collapse a burst of edits into a single run starting after + * the last one, while an expedited run must be *kept* so that a fix already in progress is never + * interrupted midway. + */ @Singleton class GeofenceOrchestrator @Inject constructor( @param:ApplicationContext private val context: Context, @@ -21,50 +30,48 @@ class GeofenceOrchestrator @Inject constructor( ) { /** - * Enqueues an expedited recalculation worker immediately. - * Safe to call from non-suspend context (e.g. BroadcastReceiver.onReceive). + * Enqueues an expedited recalculation as soon as the platform allows. + * Safe to call from a non-suspending context such as `BroadcastReceiver.onReceive`. * - * Note on FOREGROUND_SERVICE: Under Android 12+, expedited work can run immediately even - * when the app is in the background. We use [OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST] - * to fall back to a regular work request if quota is exhausted. Since this work runs in - * milliseconds (purely local DB query and geofence updates), we do not need to bind a - * Foreground Service notification, avoiding a flashing notification to the user. + * Under Android 12+ expedited work may run immediately in the background. If the quota is + * exhausted the request degrades to a normal one; since the work is a local database query + * plus a GMS call it does not need a foreground-service notification. */ fun triggerExpeditedRecalculation() { - Log.i(TAG, "Enqueuing EXPEDITED geofence recalculation...") val request = OneTimeWorkRequestBuilder() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() - enqueue(request) + enqueue(EXPEDITED_WORK_NAME, ExistingWorkPolicy.KEEP, request) } /** - * Enqueues a debounced recalculation worker. - * The debounce delay is read from [SettingsManager] and uses [ExistingWorkPolicy.REPLACE] - * so rapid successive calls collapse into a single execution. + * Enqueues a recalculation after the user-configured debounce delay. Successive calls replace + * the pending one, so a burst of edits results in a single run once the user stops changing + * things. */ suspend fun triggerRecalculation() { val debounceSecs = settingsManager.recalculationDebounceSecs.first().toLong() - Log.i(TAG, "Enqueuing debounced geofence recalculation (delay=${debounceSecs}s)...") val request = OneTimeWorkRequestBuilder() .setInitialDelay(debounceSecs, TimeUnit.SECONDS) .build() - enqueue(request) + enqueue(DEBOUNCED_WORK_NAME, ExistingWorkPolicy.REPLACE, request) } - private fun enqueue(request: OneTimeWorkRequest) { + private fun enqueue( + uniqueWorkName: String, + policy: ExistingWorkPolicy, + request: OneTimeWorkRequest + ) { try { - WorkManager.getInstance(context) - .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + WorkManager.getInstance(context).enqueueUniqueWork(uniqueWorkName, policy, request) } catch (e: IllegalStateException) { - Log.w(TAG, "WorkManager not initialized (test environment). Skipping enqueue.") - } catch (e: Exception) { - Log.e(TAG, "Failed to enqueue recalculation worker", e) + Log.w(TAG, "WorkManager not initialized; skipping recalculation.", e) } } - companion object { - private const val TAG = "GeofenceOrchestrator" - private const val WORK_NAME = "geofence_recalculation" + private companion object { + const val TAG = "GeotifyOrchestrator" + const val EXPEDITED_WORK_NAME = "geofence_recalculation_expedited" + const val DEBOUNCED_WORK_NAME = "geofence_recalculation_debounced" } } diff --git a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt index 25296ef..5654d46 100644 --- a/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt +++ b/app/src/main/java/dev/arrase/geotify/geofence/GeofenceRecalculationWorker.kt @@ -2,6 +2,7 @@ package dev.arrase.geotify.geofence import android.Manifest import android.content.Context +import android.location.Location import android.content.pm.PackageManager import android.util.Log import androidx.core.content.ContextCompat @@ -12,12 +13,21 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CancellationException import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsManager +import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.domain.SpatialSearchUseCase import dev.arrase.geotify.location.LocationProvider import kotlinx.coroutines.flow.first +/** + * Recomputes the sliding window of monitored geofences around the user's current position. + * + * The new window is registered with GMS before any derived state is persisted, so a failure + * can never leave the database claiming geofences that do not exist. + */ class GeofenceRecalculationWorker( appContext: Context, workerParams: WorkerParameters @@ -39,95 +49,135 @@ class GeofenceRecalculationWorker( applicationContext, RecalculationWorkerEntryPoint::class.java ) - val reminderRepository = entryPoint.reminderRepository() - val settingsManager = entryPoint.settingsManager() - val spatialSearchUseCase = entryPoint.spatialSearchUseCase() - val locationProvider = entryPoint.locationProvider() - val geofenceManager = entryPoint.geofenceManager() - - try { - val fineLocationPermission = ContextCompat.checkSelfPermission( - applicationContext, - Manifest.permission.ACCESS_FINE_LOCATION - ) - if (fineLocationPermission != PackageManager.PERMISSION_GRANTED) { - Log.w(TAG, "Location permission not granted. Cannot recalculate sliding window.") - return Result.failure() - } - - val location = locationProvider.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY) - if (location == null) { - Log.w(TAG, "Could not obtain current location. Retrying...") - return Result.retry() - } - - val centerLat = location.latitude - val centerLon = location.longitude - - // Save the last recalculation center - settingsManager.setLastRecalcLocation(centerLat, centerLon) - - val outerRadiusN = settingsManager.outerRadiusN.first() - val innerRadiusR = settingsManager.innerRadiusR.first() - - Log.i(TAG, "Current Location: ($centerLat, $centerLon). Radii: N=$outerRadiusN km, r=$innerRadiusR km") - val spatialCandidates = spatialSearchUseCase.execute(centerLat, centerLon, outerRadiusN) - - val activeReminders = reminderRepository.getActiveReminders() - val activeLocationIds = activeReminders.map { it.locationId }.toSet() + if (!hasLocationPermission()) { + // Not a failure: the app re-enqueues this work on the next launch or data change. + Log.w(TAG, "Location permission not granted. Skipping sliding window recalculation.") + return Result.success() + } - val activeCandidates = spatialCandidates.filter { candidate -> - activeLocationIds.contains(candidate.id) + return try { + val location = entryPoint.locationProvider() + .getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY) + ?: return retryOrGiveUp("Could not obtain current location") + + val settingsManager = entryPoint.settingsManager() + val outerRadiusKm = settingsManager.outerRadiusN.first() + val innerRadiusKm = settingsManager.innerRadiusR.first() + + val activeReminders = entryPoint.reminderRepository().getActiveReminders() + val candidates = entryPoint.spatialSearchUseCase()( + centerLat = location.latitude, + centerLon = location.longitude, + radiusKm = outerRadiusKm + ).filter { candidate -> activeReminders.any { it.locationId == candidate.id } } + + registerGeofences( + geofenceManager = entryPoint.geofenceManager(), + candidates = candidates, + activeReminders = activeReminders, + center = location, + innerRadiusMeters = innerRadiusKm * METERS_PER_KM + )?.let { + // Only persist derived state once GMS has actually accepted the new window, + // otherwise the UI would claim geofences that do not exist. + entryPoint.reminderRepository().updateInRangeStatus(candidates.map { it.id }) + settingsManager.setLastRecalcLocation(location.latitude, location.longitude) } - val activeCandidatesWithTransitions = activeCandidates.associateWith { candidate -> - activeReminders - .filter { it.locationId == candidate.id } - .map { it.transitionType } - .fold(0) { acc, type -> acc or type } - } + Result.success() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Failed to execute geofence recalculation", e) + retryOrGiveUp("Recalculation failed", e) + } + } - Log.i(TAG, "Found ${spatialCandidates.size} spatial candidate locations. Filtered to ${activeCandidates.size} with active reminders.") + /** + * Installs the new geofence window. + * + * @return `true` when GMS holds the new window, `false` when a required permission is + * missing, or `null` when there was nothing to register because no reminders are active. + */ + private suspend fun registerGeofences( + geofenceManager: GeofenceManager, + candidates: List, + activeReminders: List, + center: Location, + innerRadiusMeters: Float + ): Boolean? { + // A failure here usually means nothing was registered before, so carrying on is correct. + runCatching { geofenceManager.removeAllGeofences() } + .onFailure { Log.w(TAG, "Could not clear previous geofences", it) } + + if (activeReminders.isEmpty()) { + Log.i(TAG, "No active reminders. All geofences cleared to save battery.") + return null + } - Log.i(TAG, "Purging previous geofences...") - try { - geofenceManager.removeAllGeofences() - } catch (e: Exception) { - Log.w(TAG, "Error removing old geofences (might be none registered)", e) + return if (candidates.isNotEmpty()) { + val registered = geofenceManager.registerSlidingWindowGeofences( + locations = candidates.toTransitionMasks(activeReminders), + centerLat = center.latitude, + centerLon = center.longitude, + innerRadiusMeters = innerRadiusMeters + ) + if (registered) { + Log.i(TAG, "Registered ${candidates.size} POI geofences + master geofence") } - - reminderRepository.updateInRangeStatus(activeCandidates.map { it.id }) - - if (activeReminders.isEmpty()) { - Log.i(TAG, "No active reminders. Skipping geofence registration to save battery.") - } else if (activeCandidatesWithTransitions.isNotEmpty()) { - val innerRadiusMeters = innerRadiusR * 1000f - geofenceManager.registerSlidingWindowGeofences( - locations = activeCandidatesWithTransitions, - centerLat = centerLat, - centerLon = centerLon, - innerRadiusMeters = innerRadiusMeters - ) - Log.i(TAG, "Successfully registered ${activeCandidatesWithTransitions.size} POIs + Master Geofence") - } else { - val innerRadiusMeters = innerRadiusR * 1000f - geofenceManager.registerMasterGeofence( - centerLat = centerLat, - centerLon = centerLon, - innerRadiusMeters = innerRadiusMeters - ) - Log.i(TAG, "No candidates in range but active reminders exist. Registered Master Geofence only.") + registered + } else { + val registered = geofenceManager.registerMasterGeofence( + centerLat = center.latitude, + centerLon = center.longitude, + innerRadiusMeters = innerRadiusMeters + ) + if (registered) { + Log.i(TAG, "No POIs in range. Registered master geofence only.") } - - return Result.success() - } catch (e: Exception) { - Log.e(TAG, "Failed to execute geofence recalculation", e) - return Result.retry() + registered } } - companion object { - private const val TAG = "GeofenceRecalcWorker" + /** + * Retries with WorkManager's backoff, but gives up permanently after [MAX_ATTEMPTS] so a device + * that can never obtain a fix does not accumulate an unbounded retry chain. + */ + private fun retryOrGiveUp(reason: String, cause: Throwable? = null): Result = + if (runAttemptCount >= MAX_ATTEMPTS) { + Log.e(TAG, "$reason. Giving up after $MAX_ATTEMPTS attempts.", cause) + Result.failure() + } else { + Log.w(TAG, "$reason. Retrying (attempt ${runAttemptCount + 1}).", cause) + Result.retry() + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + private companion object { + const val TAG = "GeofenceRecalcWorker" + const val METERS_PER_KM = 1000f + const val MAX_ATTEMPTS = 3 + + /** + * Collapses the reminders attached to each candidate into the single transition bitmask + * that its geofence must monitor (GMS expects the union of the desired transitions). + * Candidates are pre-filtered to locations that have at least one active reminder, so + * every mask resolves to at least one transition. + */ + fun List.toTransitionMasks( + reminders: List + ): Map { + val masksByLocation = HashMap() + reminders.forEach { reminder -> + masksByLocation.merge(reminder.locationId, reminder.transitionType, Int::or) + } + return associateWith { location -> masksByLocation.getValue(location.id) } + } } } diff --git a/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt b/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt index 7ba01fd..c39c696 100644 --- a/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt +++ b/app/src/main/java/dev/arrase/geotify/notification/NotificationHelper.kt @@ -7,20 +7,20 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import dev.arrase.geotify.MainActivity import dev.arrase.geotify.R +import dev.arrase.geotify.ui.navigation.GeotifyTab object NotificationHelper { const val CHANNEL_GEOFENCE = "geofence_reminders" - const val EXTRA_TAB = "tab" - const val TAB_REMINDERS = "reminders" fun createNotificationChannels(context: Context) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(NotificationManager::class.java) val geofenceChannel = NotificationChannel( @@ -38,11 +38,9 @@ object NotificationHelper { /** * Shows a geofence notification for a specific reminder. * - * @param notificationId An integer ID for this notification. Note: If using `reminderId.hashCode()`, - * there is a small theoretical risk of hash collision where another active reminder - * shares the same hashCode and overwrites its notification. For this app, this is - * a reasonable trade-off to map string UUIDs to 32-bit Android notification IDs, - * as the number of simultaneously active notifications is small. + * @param notificationId Android notification id. Also used as the [PendingIntent] request code, + * so it must be unique per notification — reusing a value across two notifications makes the + * second one overwrite the first's `PendingIntent` extras. */ fun showGeofenceNotification( context: Context, @@ -50,20 +48,26 @@ object NotificationHelper { alias: String, message: String ) { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return + // `POST_NOTIFICATIONS` is a runtime permission only from Android 13 (API 33). On older + // versions the permission does not exist and checking it always reports denied, so the + // check must be skipped entirely or notifications would never be posted. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } val openIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_TAB, TAB_REMINDERS) + putExtra(MainActivity.EXTRA_TAB, GeotifyTab.Reminders.name) } val openPending = PendingIntent.getActivity( context, notificationId, openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(context, CHANNEL_GEOFENCE) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(alias) diff --git a/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt new file mode 100644 index 0000000..7b23c05 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/BaseViewModel.kt @@ -0,0 +1,53 @@ +package dev.arrase.geotify.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn + +/** How long a `stateIn` subscription stays alive after its last collector disappears. */ +private const val SUBSCRIPTION_TIMEOUT_MS = 5_000L + +/** + * Base for the app's ViewModels. Adds a buffered one-shot message channel and helpers for + * exposing settings as state and for running mutating work with uniform error reporting. + */ +abstract class BaseViewModel : ViewModel() { + + /** + * One-shot user messages. A [Channel] is used rather than a `SharedFlow` because a shared + * flow with no replay drops events emitted while nothing is collecting, which would silently + * lose exactly the error messages the user needs to see. + */ + protected val messages = Channel(Channel.BUFFERED) + val messagesFlow: Flow = messages.receiveAsFlow() + + /** Exposes a flow as state, replaying [initialValue] until the first emission. */ + protected fun Flow.settingFlow(initialValue: T): StateFlow = + stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MS), initialValue) + + /** + * Runs [action], then [onSuccess]. Cancellation is never reported as a failure — only genuine + * errors are turned into a user-visible message. [action] is the last parameter so callers can + * pass it as a trailing lambda. + */ + protected suspend fun mutate( + errorFallback: String, + onSuccess: suspend () -> Unit, + action: suspend () -> Unit + ) { + try { + action() + onSuccess() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + messages.send(errorMessage(errorFallback, e)) + } + } +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt index 1a6aab1..0d0b72f 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/MainViewModel.kt @@ -1,21 +1,17 @@ package dev.arrase.geotify.ui -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( settingsManager: SettingsManager -) : ViewModel() { +) : BaseViewModel() { - val appTheme: StateFlow = settingsManager.appTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.APP_THEME) + val appTheme: StateFlow = + settingsManager.appTheme.settingFlow(SettingsDefaults.APP_THEME) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/UiText.kt b/app/src/main/java/dev/arrase/geotify/ui/UiText.kt index b82e54c..cfc5183 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/UiText.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/UiText.kt @@ -1,18 +1,45 @@ package dev.arrase.geotify.ui import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +/** + * A user-facing message that may either be a literal string or a string resource resolved at + * composition time, so it honours the user's locale and configuration changes. + */ sealed interface UiText { data class DynamicString(val value: String) : UiText - data class StringResource(val resId: Int) : UiText + + data class StringResource( + @param:StringRes val resId: Int, + val args: List = emptyList() + ) : UiText { + constructor(@StringRes resId: Int, vararg formatArgs: Any) : this(resId, formatArgs.toList()) + } @Composable - fun asString(): String { - return when (this) { - is DynamicString -> value - is StringResource -> stringResource(resId) + fun asString(): String = when (this) { + is DynamicString -> value + is StringResource -> stringResource(resId, *args.toTypedArray()) + } + + fun resolve(context: Context): String = when (this) { + is DynamicString -> value + is StringResource -> if (args.isEmpty()) { + context.getString(resId) + } else { + context.getString(resId, *args.toTypedArray()) } } } + +/** + * Wraps a failure so the user always sees something meaningful: the cause's message when it has + * one, otherwise the supplied fallback. + */ +fun errorMessage(fallback: String, throwable: Throwable): UiText.DynamicString { + val detail = throwable.localizedMessage + return UiText.DynamicString(if (detail.isNullOrBlank()) fallback else "$fallback: $detail") +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt b/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt index a12833a..62997aa 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/EmptyState.kt @@ -1,7 +1,5 @@ package dev.arrase.geotify.ui.component -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -12,19 +10,16 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +/** + * Placeholder shown when a list has no items. Callers are expected to wrap this in an + * `AnimatedVisibility`, which provides the enter/exit transition. + */ @Composable fun EmptyState( icon: ImageVector, @@ -32,25 +27,8 @@ fun EmptyState( suggestion: String, modifier: Modifier = Modifier ) { - var visible by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { visible = true } - - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0f, - animationSpec = tween(durationMillis = 600), - label = "emptyAlpha" - ) - val scale by animateFloatAsState( - targetValue = if (visible) 1f else 0.85f, - animationSpec = tween(durationMillis = 600), - label = "emptyScale" - ) - Column( - modifier = modifier - .fillMaxSize() - .scale(scale) - .alpha(alpha), + modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt b/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt index 4900bae..0818fb6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/LocationMapView.kt @@ -14,11 +14,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import dev.arrase.geotify.data.entity.LocationEntity import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker @@ -55,17 +60,16 @@ fun LocationMapView( if (locations.size == 1) { val loc = locations.first() map.controller.setCenter(GeoPoint(loc.latitude, loc.longitude)) - map.controller.setZoom(15.0) + map.controller.setZoom(DEFAULT_ZOOM) } else { val points = locations.map { GeoPoint(it.latitude, it.longitude) } map.post { try { - val box = org.osmdroid.util.BoundingBox.fromGeoPoints(points) - map.zoomToBoundingBox(box, true, 120) + map.zoomToBoundingBox(BoundingBox.fromGeoPoints(points), true, 120) } catch (e: Exception) { val loc = locations.first() map.controller.setCenter(GeoPoint(loc.latitude, loc.longitude)) - map.controller.setZoom(15.0) + map.controller.setZoom(DEFAULT_ZOOM) } } } @@ -81,26 +85,24 @@ fun LocationMapView( } } - // Lifecycle management: onDetach() must only be called on real Activity destruction, - // NOT when AnimatedVisibility hides this composable, because onDetach() permanently - // destroys the osmdroid tile cache writer making future MapView instances unable to load tiles. - val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle + // Lifecycle management. `onDetach()` shuts down this MapView's own tile-download executor, so it + // must be called whenever the view leaves composition (e.g. the user toggles back to the list) — + // otherwise each toggle leaks a thread pool. It is safe to call repeatedly: osmdroid's tile + // writer shares a static database, so detaching does not break later MapView instances. + val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(mapViewRef, lifecycle) { val map = mapViewRef ?: return@DisposableEffect onDispose {} - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + val observer = LifecycleEventObserver { _, event -> when (event) { - androidx.lifecycle.Lifecycle.Event.ON_RESUME -> map.onResume() - androidx.lifecycle.Lifecycle.Event.ON_PAUSE -> map.onPause() - androidx.lifecycle.Lifecycle.Event.ON_DESTROY -> map.onDetach() - else -> {} + Lifecycle.Event.ON_RESUME -> map.onResume() + Lifecycle.Event.ON_PAUSE -> map.onPause() + else -> Unit } } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) - // Only pause when leaving composition (e.g. AnimatedVisibility toggling). - // Do NOT call onDetach() here — it destroys the shared tile cache writer. - map.onPause() + map.onDetach() } } @@ -133,20 +135,22 @@ fun LocationMapView( ) } + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER) - controller.setZoom(15.0) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + controller.setZoom(DEFAULT_ZOOM) onResume() mapViewRef = this } }, modifier = modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) updateLocationOverlays( map = map, locations = locations, @@ -167,6 +171,9 @@ private data class LocationOverlayStyle( val defaultStrokeColor: Int ) +/** Zoom level used when the map cannot fit all points and falls back to a single location. */ +private const val DEFAULT_ZOOM = 15.0 + private fun updateLocationOverlays( map: MapView, diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt b/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt index 1d4f8c9..6d6529a 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/LocationRow.kt @@ -9,10 +9,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import dev.arrase.geotify.R import dev.arrase.geotify.data.entity.LocationEntity -import java.util.Locale @Composable fun LocationRow( @@ -29,9 +31,8 @@ fun LocationRow( }, supportingContent = { Text( - text = String.format( - Locale.US, - "%.5f, %.5f", + text = stringResource( + R.string.coordinates_format, location.latitude, location.longitude ), @@ -40,17 +41,24 @@ fun LocationRow( ) }, leadingContent = { + // Decorative: the alias is already the first thing a screen reader announces. Icon( imageVector = Icons.Filled.LocationOn, - contentDescription = stringResource(R.string.content_description_location), + contentDescription = null, tint = MaterialTheme.colorScheme.primary ) }, trailingContent = { if (activeReminderCount > 0) { + val description = pluralStringResource( + R.plurals.active_reminder_count, + activeReminderCount, + activeReminderCount + ) Badge( containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.clearAndSetSemantics { contentDescription = description } ) { Text(activeReminderCount.toString()) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt b/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt index 42423c5..8360433 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/MapPicker.kt @@ -53,6 +53,7 @@ import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker @@ -139,19 +140,21 @@ fun MapPicker( color = MaterialTheme.colorScheme.background ) { Box(modifier = modifier.fillMaxSize()) { + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(true) - zoomController.setVisibility(org.osmdroid.views.CustomZoomButtonsController.Visibility.NEVER) - controller.setZoom(16.0) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + controller.setZoom(DEFAULT_ZOOM) mapViewRef = this } }, modifier = Modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) configureOverlays( map = map, selectedPoint = selectedPoint, @@ -358,3 +361,5 @@ private fun configureOverlays( map.invalidate() } +/** Zoom level the map opens at; the user pin is the subject of this dialog. */ +private const val DEFAULT_ZOOM = 16.0 diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt b/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt index 99f2627..d7c4256 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/MapStyles.kt @@ -6,6 +6,7 @@ import android.graphics.Canvas import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.drawable.Drawable +import android.util.LruCache import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.DrawableCompat @@ -13,34 +14,51 @@ import androidx.core.graphics.drawable.toDrawable import dev.arrase.geotify.R import org.osmdroid.views.MapView -fun applyTileThemeFilter(map: MapView, isDarkTheme: Boolean) { - if (isDarkTheme) { - val filter = ColorMatrixColorFilter( - ColorMatrix( - floatArrayOf( - -0.1491f, -0.5005f, -0.0504f, 0f, 215f, - -0.1491f, -0.5005f, -0.0504f, 0f, 215f, - -0.1491f, -0.5005f, -0.0504f, 0f, 230f, - 0f, 0f, 0f, 1f, 0f - ) - ) +/** + * Colour filter that inverts and darkens the map tiles so they blend with the dark UI theme. + * Build it once inside a `remember`; [MapView.applyTileThemeFilter] only applies it for the dark + * theme and clears it otherwise. + */ +fun darkTileFilter(): ColorMatrixColorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + -0.1491f, -0.5005f, -0.0504f, 0f, 215f, + -0.1491f, -0.5005f, -0.0504f, 0f, 215f, + -0.1491f, -0.5005f, -0.0504f, 0f, 230f, + 0f, 0f, 0f, 1f, 0f ) - map.overlayManager.tilesOverlay.setColorFilter(filter) - } else { - map.overlayManager.tilesOverlay.setColorFilter(null) - } + ) +) + +/** + * Applies the dark-tile filter, or clears it for the light theme. Build the filter once with + * [darkTileFilter] inside a `remember` and pass it in, so it is not reallocated on every + * recomposition. + */ +fun MapView.applyTileThemeFilter(isDarkTheme: Boolean, filter: ColorMatrixColorFilter?) { + overlayManager.tilesOverlay.setColorFilter(if (isDarkTheme) filter else null) } +/** + * Marker icon tinted with [color]. Building one allocates a bitmap, so results are cached by + * colour, size and screen density. + */ +private val markerIconCache = LruCache(32) + fun getTintedMarkerIcon(context: Context, color: Int, sizeDp: Int = 38): Drawable { + val density = context.resources.displayMetrics.density + val cacheKey = "$color@${sizeDp}dp@${density}x" + markerIconCache.get(cacheKey)?.let { return it } + val drawable = ContextCompat.getDrawable(context, R.drawable.ic_location) ?: return color.toDrawable() - val density = context.resources.displayMetrics.density + val size = (sizeDp * density).toInt() val bitmap = createBitmap(size, size, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bitmap) - drawable.setBounds(0, 0, size, size) val mutated = drawable.mutate() DrawableCompat.setTint(mutated, color) - mutated.draw(canvas) - return bitmap.toDrawable(context.resources) + mutated.setBounds(0, 0, size, size) + mutated.draw(Canvas(bitmap)) + + return bitmap.toDrawable(context.resources).also { markerIconCache.put(cacheKey, it) } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt index d0d81c4..f428b6d 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderMapView.kt @@ -4,6 +4,7 @@ import android.graphics.DashPathEffect import android.graphics.drawable.Drawable import android.location.Location import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -12,10 +13,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.graphics.toColorInt import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -100,7 +101,9 @@ fun ReminderMapView( } } - // Lifecycle observer + // Lifecycle management. `onDetach()` shuts down this MapView's own tile-download executor, so it + // must be called whenever the view leaves composition (e.g. the user toggles back to the list) — + // otherwise each toggle leaks a thread pool. val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(mapViewRef, lifecycle) { val map = mapViewRef ?: return@DisposableEffect onDispose {} @@ -108,41 +111,47 @@ fun ReminderMapView( when (event) { Lifecycle.Event.ON_RESUME -> map.onResume() Lifecycle.Event.ON_PAUSE -> map.onPause() - Lifecycle.Event.ON_DESTROY -> map.onDetach() - else -> {} + else -> Unit } } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) - map.onPause() + map.onDetach() } } - // Custom pins and geofence colors - val activeColor = "#FF1744".toColorInt() - val inactiveColor = "#3F51B5".toColorInt() + // Custom pins and geofence colors. The active/inactive hues are semantic (they encode reminder + // state, not the app theme), while map chrome follows the Material theme so it stays legible on + // the darkened tiles used in dark mode. + val activeColor = MaterialTheme.colorScheme.error.toArgb() + val inactiveColor = MaterialTheme.colorScheme.tertiary.toArgb() + val slidingWindowColor = MaterialTheme.colorScheme.onSurface.toArgb() + val userLocationColor = MaterialTheme.colorScheme.primary.toArgb() - val activeMarkerIcon = remember(context) { + val activeMarkerIcon = remember(context, activeColor) { getTintedMarkerIcon(context, activeColor, sizeDp = 38) } - val inactiveMarkerIcon = remember(context) { + val inactiveMarkerIcon = remember(context, inactiveColor) { getTintedMarkerIcon(context, inactiveColor, sizeDp = 38) } - val userMarkerIcon = remember(context) { - getTintedMarkerIcon(context, "#2196F3".toColorInt(), sizeDp = 24) + val userMarkerIcon = remember(context, userLocationColor) { + getTintedMarkerIcon(context, userLocationColor, sizeDp = 24) } - val centerMarkerIcon = remember(context) { - getTintedMarkerIcon(context, android.graphics.Color.BLACK, sizeDp = 20) + val centerMarkerIcon = remember(context, slidingWindowColor) { + getTintedMarkerIcon(context, slidingWindowColor, sizeDp = 20) } - val activeFillColor = android.graphics.Color.argb(55, 255, 23, 68) - val inactiveFillColor = android.graphics.Color.argb(35, 63, 81, 181) + val activeFillColor = activeColor.withAlpha(FILL_ALPHA) + val inactiveFillColor = inactiveColor.withAlpha(FILL_ALPHA) val labelSlidingWindowCenter = stringResource(R.string.label_sliding_window_center) val labelMyLocation = stringResource(R.string.label_my_location) - val markerStyle = remember(activeMarkerIcon, inactiveMarkerIcon) { + val markerStyle = remember( + activeMarkerIcon, inactiveMarkerIcon, activeColor, inactiveColor, + activeFillColor, inactiveFillColor + ) { ReminderMarkerStyle( activeIcon = activeMarkerIcon, inactiveIcon = inactiveMarkerIcon, @@ -153,6 +162,8 @@ fun ReminderMapView( ) } + val tileFilter = remember { darkTileFilter() } + AndroidView( factory = { ctx -> MapView(ctx).apply { @@ -166,7 +177,7 @@ fun ReminderMapView( }, modifier = modifier.fillMaxSize(), update = { map -> - applyTileThemeFilter(map, isDarkTheme) + map.applyTileThemeFilter(isDarkTheme, tileFilter) map.overlays.clear() // Deselect single tap listener @@ -180,7 +191,13 @@ fun ReminderMapView( }) map.overlays.add(mapEventsOverlay) - drawSpatialCircles(map, data.spatialArea, centerMarkerIcon, labelSlidingWindowCenter) + drawSpatialCircles( + map = map, + spatialArea = data.spatialArea, + centerMarkerIcon = centerMarkerIcon, + centerLabel = labelSlidingWindowCenter, + slidingWindowColor = slidingWindowColor + ) drawUserLocationMarker(map, data.currentUserLocation, userMarkerIcon, labelMyLocation) drawReminderMarkers( map = map, @@ -243,7 +260,8 @@ private fun drawSpatialCircles( map: MapView, spatialArea: SpatialRecalculationArea, centerMarkerIcon: Drawable, - centerLabel: String + centerLabel: String, + slidingWindowColor: Int ) { val lat = spatialArea.latitude val lng = spatialArea.longitude @@ -253,8 +271,8 @@ private fun drawSpatialCircles( // 1a. Outer radius — spatial search area (dashed, subtle) val outerCircle = Polygon().apply { points = Polygon.pointsAsCircle(centerPoint, spatialArea.outerRadiusMeters.toDouble()) - fillPaint.color = android.graphics.Color.argb(25, 0, 0, 0) - outlinePaint.color = android.graphics.Color.argb(180, 60, 60, 60) + fillPaint.color = slidingWindowColor.withAlpha(25) + outlinePaint.color = slidingWindowColor.withAlpha(180) outlinePaint.strokeWidth = 3f outlinePaint.pathEffect = DashPathEffect(floatArrayOf(20f, 15f), 0f) } @@ -263,8 +281,8 @@ private fun drawSpatialCircles( // 1b. Inner radius — master geofence boundary (solid, prominent) val innerCircle = Polygon().apply { points = Polygon.pointsAsCircle(centerPoint, spatialArea.innerRadiusMeters.toDouble()) - fillPaint.color = android.graphics.Color.argb(18, 0, 0, 0) - outlinePaint.color = android.graphics.Color.BLACK + fillPaint.color = slidingWindowColor.withAlpha(18) + outlinePaint.color = slidingWindowColor outlinePaint.strokeWidth = 5f } map.overlays.add(innerCircle) @@ -371,3 +389,10 @@ private fun drawReminderMarkers( ) } } + +/** Opacity applied to the semi-transparent fill inside a geofence circle. */ +private const val FILL_ALPHA = 55 + +/** Returns this opaque ARGB color with only its alpha replaced. */ +private fun Int.withAlpha(alpha: Int): Int = + (this and 0x00FFFFFF) or (alpha.coerceIn(0, 255) shl 24) diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt index 5d98380..dee75b6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderRow.kt @@ -3,15 +3,11 @@ package dev.arrase.geotify.ui.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SuggestionChip -import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -19,7 +15,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.arrase.geotify.R import dev.arrase.geotify.data.entity.ReminderEntity -import dev.arrase.geotify.data.entity.isArrival @Composable fun ReminderRow( @@ -44,28 +39,10 @@ fun ReminderRow( ) } }, - supportingContent = if (reminder.isActive && reminder.isInRange) { - { - SuggestionChip( - onClick = {}, - label = { - Text( - text = stringResource(R.string.label_in_range), - style = MaterialTheme.typography.labelSmall - ) - }, - colors = SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - modifier = Modifier.height(24.dp) - ) - } - } else null, leadingContent = { Icon( imageVector = Icons.Filled.Notifications, - contentDescription = stringResource(R.string.content_description_reminder), + contentDescription = null, tint = if (reminder.isActive) { MaterialTheme.colorScheme.primary } else { @@ -73,29 +50,7 @@ fun ReminderRow( } ) }, - trailingContent = { - SuggestionChip( - onClick = {}, - label = { - Text( - text = if (reminder.isArrival) { - stringResource(R.string.label_transition_arrival) - } else { - stringResource(R.string.label_transition_departure) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - icon = { - Icon( - imageVector = Icons.Filled.Notifications, - contentDescription = null, - modifier = Modifier.size(AssistChipDefaults.IconSize) - ) - }, - modifier = Modifier.height(24.dp) - ) - }, + trailingContent = { ReminderStatusChips(reminder) }, modifier = modifier ) } diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt new file mode 100644 index 0000000..9438f86 --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/component/ReminderStatusChips.kt @@ -0,0 +1,75 @@ +package dev.arrase.geotify.ui.component + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.arrase.geotify.R +import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.data.entity.isArrival +import dev.arrase.geotify.data.entity.isDeparture + +/** + * Read-only chips describing a reminder's transition and, when it is being monitored, that it is + * within the current recalculation area. These are status indicators, not controls, so they expose + * no click behaviour. + */ +@Composable +fun ReminderStatusChips( + reminder: ReminderEntity, + modifier: Modifier = Modifier +) { + val transitionLabel = when { + reminder.isArrival -> stringResource(R.string.label_transition_arrival) + reminder.isDeparture -> stringResource(R.string.label_transition_departure) + else -> stringResource(R.string.label_transition_unknown) + } + + StatusChip(label = transitionLabel, modifier = modifier) + + if (reminder.isActive && reminder.isInRange) { + StatusChip( + label = stringResource(R.string.label_in_range), + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + labelColor = MaterialTheme.colorScheme.onTertiaryContainer + ) + } +} + +@Composable +private fun StatusChip( + label: String, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + labelColor: Color = MaterialTheme.colorScheme.onSecondaryContainer +) { + SuggestionChip( + onClick = {}, + label = { + Text(text = label, style = MaterialTheme.typography.labelSmall) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = containerColor, + labelColor = labelColor + ), + icon = { + Icon( + imageVector = Icons.Filled.Notifications, + contentDescription = null, + modifier = Modifier.size(AssistChipDefaults.IconSize) + ) + }, + modifier = modifier.height(24.dp) + ) +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt b/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt index 829f087..32f9a13 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/component/SwipeToDeleteBackground.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -40,6 +39,7 @@ fun SwipeToDeleteBackground( } } +/** Swipe-to-delete wrapper that only allows deleting by swiping from the trailing edge. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SwipeToDeleteContainer( @@ -50,17 +50,15 @@ fun SwipeToDeleteContainer( ) { val dismissState = rememberSwipeToDismissBoxState() - LaunchedEffect(dismissState.currentValue) { - if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart) { - onDelete() - } - } - SwipeToDismissBox( state = dismissState, backgroundContent = { SwipeToDeleteBackground(contentDescription = contentDescription) }, + // Fires once when the row settles past the threshold, and only for a real dismissal. + onDismiss = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) onDelete() + }, enableDismissFromStartToEnd = false, modifier = modifier, content = { content() } diff --git a/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt b/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt index 0c94445..69f6aa0 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/navigation/GeotifyNavigation.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.ui.navigation +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.fadeIn @@ -51,6 +52,11 @@ fun GeotifyNavHost( var selectedTab by rememberSaveable { mutableStateOf(initialTab) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + // Back from a secondary tab returns to the default one instead of leaving the app. + BackHandler(enabled = selectedTab != GeotifyTab.Reminders) { + selectedTab = GeotifyTab.Reminders + } + Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -85,9 +91,10 @@ fun GeotifyNavHost( transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "tabContent" ) { tab -> + // Exhaustive `when` expression: adding a tab without rendering it fails to compile. when (tab) { - GeotifyTab.Locations -> LocationsScreen(viewModel = hiltViewModel()) GeotifyTab.Reminders -> RemindersScreen(viewModel = hiltViewModel()) + GeotifyTab.Locations -> LocationsScreen(viewModel = hiltViewModel()) GeotifyTab.Settings -> SettingsScreen(viewModel = hiltViewModel()) } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt index bc5bdad..8bf3c32 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -72,9 +71,8 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.arrase.geotify.R -import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.ui.UiText +import dev.arrase.geotify.ui.theme.resolve import dev.arrase.geotify.ui.component.BackgroundLocationWarningBanner import dev.arrase.geotify.ui.component.DialogDismissButtons import dev.arrase.geotify.ui.component.EmptyState @@ -86,12 +84,6 @@ import dev.arrase.geotify.ui.component.ViewModeSwitcher import kotlinx.coroutines.launch import java.util.Locale -private fun isMapDark(themeSetting: ThemeSetting, isSystemDark: Boolean): Boolean = when (themeSetting) { - ThemeSetting.SYSTEM -> isSystemDark - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true -} - private fun isValidCoordinate(coord: Double?, min: Double, max: Double): Boolean = coord != null && coord in min..max @@ -133,42 +125,42 @@ fun LocationsScreen( val context = LocalContext.current val mapThemeSetting by viewModel.mapTheme.collectAsStateWithLifecycle() val isSystemDark = androidx.compose.foundation.isSystemInDarkTheme() - val isMapDarkTheme = isMapDark(mapThemeSetting, isSystemDark) + val isMapDarkTheme = mapThemeSetting.resolve(isSystemDark) val locations by viewModel.locations.collectAsStateWithLifecycle() val activeReminderCounts by viewModel.activeReminderCounts.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() LaunchedEffect(viewModel) { - viewModel.snackbarMessage.collect { uiText -> - val msg = when (uiText) { - is UiText.DynamicString -> uiText.value - is UiText.StringResource -> context.applicationContext.getString(uiText.resId) - } + viewModel.messagesFlow.collect { message -> snackbarHostState.showSnackbar( - message = msg, + message = message.resolve(context), duration = SnackbarDuration.Short ) } } - var showDialog by remember { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var editingLocation by remember { mutableStateOf(null) } - var alias by remember { mutableStateOf("") } - var latitudeString by remember { mutableStateOf("") } - var longitudeString by remember { mutableStateOf("") } - var radiusMeters by remember { mutableFloatStateOf(150f) } - var responsivenessMinutes by remember { mutableFloatStateOf(0f) } - var showResponsivenessInfo by remember { mutableStateOf(false) } + var editingLocationId by rememberSaveable { mutableStateOf(null) } + var alias by rememberSaveable { mutableStateOf("") } + var latitudeString by rememberSaveable { mutableStateOf("") } + var longitudeString by rememberSaveable { mutableStateOf("") } + var radiusMeters by rememberSaveable { mutableFloatStateOf(150f) } + var responsivenessMinutes by rememberSaveable { mutableFloatStateOf(0f) } + var showResponsivenessInfo by rememberSaveable { mutableStateOf(false) } var isGpsLoading by remember { mutableStateOf(false) } var isMapView by rememberSaveable { mutableStateOf(false) } var selectedLocationOnMap by remember { mutableStateOf(null) } - var showMapPicker by remember { mutableStateOf(false) } + var showMapPicker by rememberSaveable { mutableStateOf(false) } + + val editingLocation = remember(editingLocationId, locations) { + locations.firstOrNull { it.id == editingLocationId } + } val aliasExists = remember(alias, editingLocation, locations) { - locations.any { it.alias.equals(alias, ignoreCase = true) && it.id != editingLocation?.id } + locations.any { it.alias.equals(alias.trim(), ignoreCase = true) && it.id != editingLocation?.id } } val lat = latitudeString.toDoubleOrNull() @@ -177,7 +169,7 @@ fun LocationsScreen( val isLongitudeValid = isValidCoordinate(lng, -180.0, 180.0) fun openFormForNew() { - editingLocation = null + editingLocationId = null alias = "" latitudeString = "" longitudeString = "" @@ -187,7 +179,7 @@ fun LocationsScreen( } fun openFormForEditing(location: LocationEntity) { - editingLocation = location + editingLocationId = location.id alias = location.alias latitudeString = location.latitude.toString() longitudeString = location.longitude.toString() @@ -240,24 +232,18 @@ fun LocationsScreen( onSave = { saveLocation(viewModel, editingLocation, alias, lat, lng, radiusMeters, responsivenessMinutes) showDialog = false - editingLocation = null + editingLocationId = null }, - onDelete = if (editingLocation != null) { + onDelete = editingLocation?.let { location -> { - viewModel.deleteLocation(editingLocation!!.alias) + viewModel.deleteLocation(location) showDialog = false - editingLocation = null - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_location_deleted, alias), - duration = SnackbarDuration.Short - ) - } + editingLocationId = null } - } else null, + }, onCancel = { showDialog = false - editingLocation = null + editingLocationId = null }, onResponsivenessInfoChange = { showResponsivenessInfo = it } ) @@ -275,16 +261,10 @@ fun LocationsScreen( onMapSelected = { isMapView = true }, onLocationSelected = { selectedLocationOnMap = it }, onDeleteLocation = { location -> - viewModel.deleteLocation(location.alias) + viewModel.deleteLocation(location) if (selectedLocationOnMap?.id == location.id) { selectedLocationOnMap = null } - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_location_deleted, location.alias), - duration = SnackbarDuration.Short - ) - } }, onEditLocation = { location -> openFormForEditing(location) }, onDismissSelectedLocation = { selectedLocationOnMap = null }, @@ -337,7 +317,7 @@ fun LocationsScreen( formActions = formActions, onDismissRequest = { showDialog = false - editingLocation = null + editingLocationId = null } ) } @@ -948,8 +928,13 @@ private fun SelectedLocationCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = stringResource(R.string.label_latitude) + String.format(Locale.US, ": %.5f, ", location.latitude) + - stringResource(R.string.label_longitude) + String.format(Locale.US, ": %.5f", location.longitude), + text = stringResource( + R.string.label_coordinates_pair, + stringResource(R.string.label_latitude), + location.latitude, + stringResource(R.string.label_longitude), + location.longitude + ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt index e01e8d0..aa90a47 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/LocationsViewModel.kt @@ -1,8 +1,8 @@ package dev.arrase.geotify.ui.screen -import android.location.Location -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arrase.geotify.R import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults @@ -11,15 +11,10 @@ import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider +import dev.arrase.geotify.ui.BaseViewModel import dev.arrase.geotify.ui.UiText -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,54 +24,69 @@ class LocationsViewModel @Inject constructor( private val reminderRepository: ReminderRepository, private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider, - private val settingsManager: SettingsManager -) : ViewModel() { - - private val _snackbarMessage = MutableSharedFlow() - val snackbarMessage: SharedFlow = _snackbarMessage.asSharedFlow() + settingsManager: SettingsManager +) : BaseViewModel() { val locations: StateFlow> = locationRepository.observeLocations() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .settingFlow(emptyList()) - val activeReminderCounts: StateFlow> = reminderRepository.observeActiveReminderCounts() - .map { list -> list.associate { it.locationId to it.count } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + val activeReminderCounts: StateFlow> = + reminderRepository.observeActiveReminderCounts() + .map { counts -> counts.associate { it.locationId to it.count } } + .settingFlow(emptyMap()) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - fun saveLocation(alias: String, latitude: Double, longitude: Double, radiusMeters: Float, notificationResponsivenessMs: Int) { - viewModelScope.launch { - try { - locationRepository.saveLocation(alias, latitude, longitude, radiusMeters, notificationResponsivenessMs) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error saving location")) - } + fun saveLocation( + alias: String, + latitude: Double, + longitude: Double, + radiusMeters: Float, + notificationResponsivenessMs: Int + ) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + locationRepository.saveLocation( + alias = alias, + latitude = latitude, + longitude = longitude, + radiusMeters = radiusMeters, + notificationResponsivenessMs = notificationResponsivenessMs + ) } } - fun updateLocation(location: LocationEntity) { - viewModelScope.launch { - try { - locationRepository.updateLocation(location) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error updating location")) - } + fun updateLocation(location: LocationEntity) = viewModelScope.launch { + mutate(ERROR_UPDATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + locationRepository.updateLocation(location) } } - fun deleteLocation(alias: String) { - viewModelScope.launch { - try { - locationRepository.deleteLocation(alias) + fun deleteLocation(location: LocationEntity) = viewModelScope.launch { + mutate( + errorFallback = ERROR_DELETE, + onSuccess = { geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString(e.localizedMessage ?: "Unknown error deleting location")) + messages.send( + UiText.StringResource(R.string.location_deleted, location.alias) + ) + } + ) { + if (!locationRepository.deleteLocation(location.alias)) { + throw StaleLocationException(location.id) } } } - suspend fun getCurrentLocation(): Location? = locationProvider.getCurrentLocation() + suspend fun getCurrentLocation() = locationProvider.getCurrentLocation() + + private companion object { + const val ERROR_SAVE = "Could not save the location" + const val ERROR_UPDATE = "Could not update the location" + const val ERROR_DELETE = "Could not delete the location" + } } + +/** Raised when a delete affected no rows, i.e. the location was already gone. */ +private class StaleLocationException(id: String) : + IllegalStateException("Location $id no longer exists") diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt index 438789c..3dbdaf3 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersScreen.kt @@ -61,7 +61,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -74,27 +73,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.google.android.gms.location.Geofence import dev.arrase.geotify.R -import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.data.entity.isArrival -import dev.arrase.geotify.ui.UiText +import dev.arrase.geotify.ui.theme.resolve import dev.arrase.geotify.ui.component.BackgroundLocationWarningBanner import dev.arrase.geotify.ui.component.DialogDismissButtons import dev.arrase.geotify.ui.component.EmptyState import dev.arrase.geotify.ui.component.ReminderMapView import dev.arrase.geotify.ui.component.ReminderMapViewData import dev.arrase.geotify.ui.component.ReminderRow +import dev.arrase.geotify.ui.component.ReminderStatusChips import dev.arrase.geotify.ui.component.SpatialRecalculationArea import dev.arrase.geotify.ui.component.SwipeToDeleteContainer import dev.arrase.geotify.ui.component.ViewModeSwitcher -import kotlinx.coroutines.launch - -private fun isMapDark(themeSetting: ThemeSetting, isSystemDark: Boolean): Boolean = when (themeSetting) { - ThemeSetting.SYSTEM -> isSystemDark - ThemeSetting.LIGHT -> false - ThemeSetting.DARK -> true -} private fun saveReminder( viewModel: RemindersViewModel, @@ -126,24 +118,18 @@ fun RemindersScreen( val reminders by viewModel.reminders.collectAsStateWithLifecycle() val locations by viewModel.locations.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() val mapThemeSetting by viewModel.mapTheme.collectAsStateWithLifecycle() - val lastRecalcLat by viewModel.lastRecalcLat.collectAsStateWithLifecycle() - val lastRecalcLng by viewModel.lastRecalcLng.collectAsStateWithLifecycle() + val lastRecalcLocation by viewModel.lastRecalcLocation.collectAsStateWithLifecycle() val innerRadiusR by viewModel.innerRadiusR.collectAsStateWithLifecycle() val outerRadiusN by viewModel.outerRadiusN.collectAsStateWithLifecycle() val isSystemDark = androidx.compose.foundation.isSystemInDarkTheme() - val isMapDarkTheme = isMapDark(mapThemeSetting, isSystemDark) + val isMapDarkTheme = mapThemeSetting.resolve(isSystemDark) LaunchedEffect(viewModel) { - viewModel.snackbarMessage.collect { uiText -> - val msg = when (uiText) { - is UiText.DynamicString -> uiText.value - is UiText.StringResource -> context.applicationContext.getString(uiText.resId) - } + viewModel.messagesFlow.collect { message -> snackbarHostState.showSnackbar( - message = msg, + message = message.resolve(context), duration = SnackbarDuration.Short ) } @@ -178,14 +164,18 @@ fun RemindersScreen( } // Dialog State - var showDialog by remember { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var editingReminder by remember { mutableStateOf(null) } - var selectedLocationId by remember { mutableStateOf("") } - var message by remember { mutableStateOf("") } - var transitionType by remember { mutableIntStateOf(Geofence.GEOFENCE_TRANSITION_ENTER) } + var editingReminderId by rememberSaveable { mutableStateOf(null) } + var selectedLocationId by rememberSaveable { mutableStateOf("") } + var message by rememberSaveable { mutableStateOf("") } + var transitionType by rememberSaveable { mutableIntStateOf(Geofence.GEOFENCE_TRANSITION_ENTER) } var dropdownExpanded by remember { mutableStateOf(false) } + val editingReminder = remember(editingReminderId, reminders) { + reminders.firstOrNull { it.id == editingReminderId } + } + val formState = ReminderFormState( editingReminder = editingReminder, selectedLocationId = selectedLocationId, @@ -205,25 +195,19 @@ fun RemindersScreen( onSave = { saveReminder(viewModel, editingReminder, selectedLocationId, message, transitionType) showDialog = false - editingReminder = null + editingReminderId = null }, onDelete = { val editing = editingReminder if (editing != null) { viewModel.cancelReminder(editing.id) showDialog = false - editingReminder = null - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } + editingReminderId = null } }, onCancel = { showDialog = false - editingReminder = null + editingReminderId = null } ) @@ -240,52 +224,30 @@ fun RemindersScreen( locations = locations, selectedLocation = selectedLocationOnMap, spatialArea = SpatialRecalculationArea( - latitude = lastRecalcLat, - longitude = lastRecalcLng, - innerRadiusMeters = innerRadiusR * 1000f, - outerRadiusMeters = outerRadiusN * 1000f - ), - currentUserLocation = currentUserLocation + latitude = lastRecalcLocation?.latitude, + longitude = lastRecalcLocation?.longitude, + innerRadiusMeters = innerRadiusR * 1000f, + outerRadiusMeters = outerRadiusN * 1000f ), - isMapDarkTheme = isMapDarkTheme - ) + currentUserLocation = currentUserLocation + ), + isMapDarkTheme = isMapDarkTheme +) val contentActions = RemindersContentActions( onListSelected = { isMapView = false }, onMapSelected = { isMapView = true }, - onCancelActive = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_cancelled), - duration = SnackbarDuration.Short - ) - } - }, - onDeleteCompleted = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } - }, + onCancelActive = { reminder -> viewModel.cancelReminder(reminder.id) }, + onDeleteCompleted = { reminder -> viewModel.cancelReminder(reminder.id, cancelled = false) }, onEditReminder = { reminder -> - editingReminder = reminder + editingReminderId = reminder.id selectedLocationId = reminder.locationId message = reminder.message transitionType = reminder.transitionType showDialog = true }, onDeleteReminderOnMap = { reminder -> - viewModel.cancelReminder(reminder.id) - scope.launch { - snackbarHostState.showSnackbar( - message = context.applicationContext.getString(R.string.toast_reminder_deleted), - duration = SnackbarDuration.Short - ) - } + viewModel.cancelReminder(reminder.id, cancelled = false) val remaining = activeReminders.filter { it.locationId == selectedLocationOnMap?.id && it.id != reminder.id } if (remaining.isEmpty()) { selectedLocationOnMap = null @@ -294,7 +256,7 @@ fun RemindersScreen( onLocationSelected = { selectedLocationOnMap = it }, onDismissSelectedLocation = { selectedLocationOnMap = null }, onAddReminder = { - editingReminder = null + editingReminderId = null selectedLocationId = locations.firstOrNull()?.id ?: "" message = "" transitionType = Geofence.GEOFENCE_TRANSITION_ENTER @@ -331,7 +293,7 @@ fun RemindersScreen( locations = locations, onDismissRequest = { showDialog = false - editingReminder = null + editingReminderId = null } ) } @@ -891,42 +853,7 @@ private fun SelectedReminderItemRow( fontWeight = FontWeight.SemiBold ) Spacer(Modifier.height(4.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - androidx.compose.material3.SuggestionChip( - onClick = {}, - label = { - Text( - text = if (reminder.isArrival) { - stringResource(R.string.label_transition_arrival) - } else { - stringResource(R.string.label_transition_departure) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - modifier = Modifier.height(24.dp) - ) - - if (reminder.isInRange) { - androidx.compose.material3.SuggestionChip( - onClick = {}, - label = { - Text( - text = stringResource(R.string.label_in_range), - style = MaterialTheme.typography.labelSmall - ) - }, - colors = androidx.compose.material3.SuggestionChipDefaults.suggestionChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer - ), - modifier = Modifier.height(24.dp) - ) - } - } + ReminderStatusChips(reminder) } Row( diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt index 5f5f32d..16d5bc4 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/RemindersViewModel.kt @@ -1,25 +1,21 @@ package dev.arrase.geotify.ui.screen -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.arrase.geotify.R +import dev.arrase.geotify.data.LatLng import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository -import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting +import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider +import dev.arrase.geotify.ui.BaseViewModel import dev.arrase.geotify.ui.UiText -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,72 +25,76 @@ class RemindersViewModel @Inject constructor( private val reminderRepository: ReminderRepository, private val geofenceOrchestrator: GeofenceOrchestrator, private val locationProvider: LocationProvider, - private val settingsManager: SettingsManager -) : ViewModel() { - - private val _snackbarMessage = MutableSharedFlow() - val snackbarMessage: SharedFlow = _snackbarMessage.asSharedFlow() + settingsManager: SettingsManager +) : BaseViewModel() { val locations: StateFlow> = locationRepository.observeLocations() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .settingFlow(emptyList()) val reminders: StateFlow> = reminderRepository.observeReminders() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - val activeReminderCounts: StateFlow> = reminderRepository.observeActiveReminderCounts() - .map { list -> list.associate { it.locationId to it.count } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + .settingFlow(emptyList()) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - val lastRecalcLat: StateFlow = settingsManager.lastRecalcLat - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val lastRecalcLocation: StateFlow = settingsManager.lastRecalcLocation + .settingFlow(null) - val lastRecalcLng: StateFlow = settingsManager.lastRecalcLng - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val innerRadiusR: StateFlow = + settingsManager.innerRadiusR.settingFlow(SettingsDefaults.INNER_RADIUS_R) - val innerRadiusR: StateFlow = settingsManager.innerRadiusR - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.INNER_RADIUS_R) + val outerRadiusN: StateFlow = + settingsManager.outerRadiusN.settingFlow(SettingsDefaults.OUTER_RADIUS_N) - val outerRadiusN: StateFlow = settingsManager.outerRadiusN - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.OUTER_RADIUS_N) - - suspend fun getCurrentLocation(): android.location.Location? { - return locationProvider.getCurrentLocation() - } - - fun createReminder(locationId: String, message: String, transitionType: Int) { + fun createReminder(locationId: String, message: String, transitionType: Int) = viewModelScope.launch { - try { - val location = locationRepository.findLocationById(locationId) ?: return@launch + val location = locationRepository.findLocationById(locationId) + if (location == null) { + messages.send(UiText.StringResource(R.string.err_location_missing)) + return@launch + } + mutate(ERROR_CREATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { reminderRepository.createReminder(location, message, transitionType) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) } } - } - fun updateReminder(reminder: ReminderEntity) { - viewModelScope.launch { - try { - reminderRepository.updateReminder(reminder) - geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) - } + fun updateReminder(reminder: ReminderEntity) = viewModelScope.launch { + mutate(ERROR_UPDATE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + reminderRepository.updateReminder(reminder) } } - fun cancelReminder(reminderId: String) { - viewModelScope.launch { - try { - reminderRepository.cancelReminder(reminderId) + /** + * Removes a reminder. [cancelled] distinguishes stopping an active reminder (it stays in the + * completed list) from deleting a completed one, so the confirmation matches the action. + */ + fun cancelReminder(reminderId: String, cancelled: Boolean = true) = viewModelScope.launch { + mutate( + errorFallback = ERROR_CANCEL, + onSuccess = { geofenceOrchestrator.triggerRecalculation() - } catch (e: Exception) { - _snackbarMessage.emit(UiText.DynamicString("Error: ${e.message}")) + messages.send( + UiText.StringResource( + if (cancelled) R.string.reminder_cancelled else R.string.reminder_deleted + ) + ) + } + ) { + if (!reminderRepository.cancelReminder(reminderId)) { + throw StaleReminderException(reminderId) } } } + + suspend fun getCurrentLocation() = locationProvider.getCurrentLocation() + + private companion object { + const val ERROR_CREATE = "Could not create the reminder" + const val ERROR_UPDATE = "Could not update the reminder" + const val ERROR_CANCEL = "Could not cancel the reminder" + } } + +/** Raised when a delete affected no rows, i.e. the reminder was already gone. */ +private class StaleReminderException(id: String) : + IllegalStateException("Reminder $id no longer exists") diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt index d43c380..ec466e6 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsScreen.kt @@ -106,8 +106,9 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { val outerRadiusN by viewModel.outerRadiusN.collectAsStateWithLifecycle() val innerRadiusR by viewModel.innerRadiusR.collectAsStateWithLifecycle() - var localOuterRadius by remember(outerRadiusN) { mutableFloatStateOf(outerRadiusN) } - var localInnerRadius by remember(innerRadiusR) { mutableFloatStateOf(innerRadiusR) } + // Seeded once: re-keying on the upstream flow would reset the slider mid-drag. + var localOuterRadius by remember { mutableFloatStateOf(outerRadiusN) } + var localInnerRadius by remember { mutableFloatStateOf(innerRadiusR.coerceIn(MIN_RADIUS_KM, outerRadiusN)) } var showRecalcInfo by remember { mutableStateOf(false) } SettingsCard( @@ -144,15 +145,16 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { onValueChange = { newVal -> val cleanVal = round(newVal * 10f) / 10f localOuterRadius = cleanVal - if (localInnerRadius > cleanVal) { - localInnerRadius = cleanVal - } + // The inner radius must never exceed the outer one. + localInnerRadius = + localInnerRadius.coerceIn(MIN_RADIUS_KM, cleanVal) }, onValueChangeFinished = { - viewModel.setOuterRadiusN(localOuterRadius) - if (innerRadiusR > localOuterRadius) { - viewModel.setInnerRadiusR(localOuterRadius) - } + // Both radii feed the same recalculation, so they are persisted together. + viewModel.setRadii( + outerRadiusKm = localOuterRadius, + innerRadiusKm = localInnerRadius + ) }, valueRange = 1.0f..10.0f ) @@ -180,13 +182,16 @@ private fun SpatialRecalculationSection(viewModel: SettingsViewModel) { Slider( value = localInnerRadius, onValueChange = { newVal -> - val cleanVal = round(newVal * 10f) / 10f - localInnerRadius = cleanVal + localInnerRadius = (round(newVal * 10f) / 10f) + .coerceIn(MIN_RADIUS_KM, localOuterRadius) }, onValueChangeFinished = { - viewModel.setInnerRadiusR(localInnerRadius) + viewModel.setRadii( + outerRadiusKm = localOuterRadius, + innerRadiusKm = localInnerRadius + ) }, - valueRange = 0.5f..localOuterRadius + valueRange = MIN_RADIUS_KM..localOuterRadius ) } } @@ -519,3 +524,6 @@ private fun ThemeSelector( } } } + +/** Smallest selectable inner radius, in km. */ +private const val MIN_RADIUS_KM = 0.5f diff --git a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt index 311e883..85f808f 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/screen/SettingsViewModel.kt @@ -1,15 +1,13 @@ package dev.arrase.geotify.ui.screen -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.geofence.GeofenceOrchestrator -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted +import dev.arrase.geotify.ui.BaseViewModel import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,81 +15,77 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val settingsManager: SettingsManager, private val geofenceOrchestrator: GeofenceOrchestrator -) : ViewModel() { +) : BaseViewModel() { - val appTheme: StateFlow = settingsManager.appTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.APP_THEME) + val appTheme: StateFlow = + settingsManager.appTheme.settingFlow(SettingsDefaults.APP_THEME) - val mapTheme: StateFlow = settingsManager.mapTheme - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MAP_THEME) + val mapTheme: StateFlow = + settingsManager.mapTheme.settingFlow(SettingsDefaults.MAP_THEME) - val outerRadiusN: StateFlow = settingsManager.outerRadiusN - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.OUTER_RADIUS_N) + val outerRadiusN: StateFlow = + settingsManager.outerRadiusN.settingFlow(SettingsDefaults.OUTER_RADIUS_N) - val innerRadiusR: StateFlow = settingsManager.innerRadiusR - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.INNER_RADIUS_R) + val innerRadiusR: StateFlow = + settingsManager.innerRadiusR.settingFlow(SettingsDefaults.INNER_RADIUS_R) val locationCacheTimeoutSecs: StateFlow = settingsManager.locationCacheTimeoutSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) + .settingFlow(SettingsDefaults.LOCATION_CACHE_TIMEOUT_SECS) val recalculationDebounceSecs: StateFlow = settingsManager.recalculationDebounceSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) + .settingFlow(SettingsDefaults.RECALCULATION_DEBOUNCE_SECS) - val masterGeofenceResponsivenessSecs: StateFlow = settingsManager.masterGeofenceResponsivenessSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) + val masterGeofenceResponsivenessSecs: StateFlow = + settingsManager.masterGeofenceResponsivenessSecs + .settingFlow(SettingsDefaults.MASTER_GEOFENCE_RESPONSIVENESS_SECS) val poiGeofenceResponsivenessSecs: StateFlow = settingsManager.poiGeofenceResponsivenessSecs - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) + .settingFlow(SettingsDefaults.POI_GEOFENCE_RESPONSIVENESS_SECS) - fun setAppTheme(theme: ThemeSetting) { - viewModelScope.launch { - settingsManager.setAppTheme(theme) - } + fun setAppTheme(theme: ThemeSetting) = persist { + settingsManager.setAppTheme(theme) } - fun setMapTheme(theme: ThemeSetting) { - viewModelScope.launch { - settingsManager.setMapTheme(theme) - } + fun setMapTheme(theme: ThemeSetting) = persist { + settingsManager.setMapTheme(theme) } - fun setOuterRadiusN(radius: Float) { - viewModelScope.launch { - settingsManager.setOuterRadiusN(radius) - geofenceOrchestrator.triggerRecalculation() - } + fun setLocationCacheTimeoutSecs(secs: Int) = persist { + settingsManager.setLocationCacheTimeoutSecs(secs) } - fun setInnerRadiusR(radius: Float) { - viewModelScope.launch { - settingsManager.setInnerRadiusR(radius) - geofenceOrchestrator.triggerRecalculation() - } + fun setRecalculationDebounceSecs(secs: Int) = persist { + settingsManager.setRecalculationDebounceSecs(secs) } - fun setLocationCacheTimeoutSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setLocationCacheTimeoutSecs(secs) + /** + * The sliding window depends on both radii, so they are written together and the + * recalculation is triggered once, after both have been persisted. + */ + fun setRadii(outerRadiusKm: Float, innerRadiusKm: Float) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation) { + settingsManager.setOuterRadiusN(outerRadiusKm) + settingsManager.setInnerRadiusR(innerRadiusKm) } } - fun setRecalculationDebounceSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setRecalculationDebounceSecs(secs) - } + fun setMasterGeofenceResponsivenessSecs(secs: Int) = recalculate { + settingsManager.setMasterGeofenceResponsivenessSecs(secs) } - fun setMasterGeofenceResponsivenessSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setMasterGeofenceResponsivenessSecs(secs) - geofenceOrchestrator.triggerRecalculation() - } + fun setPoiGeofenceResponsivenessSecs(secs: Int) = recalculate { + settingsManager.setPoiGeofenceResponsivenessSecs(secs) } - fun setPoiGeofenceResponsivenessSecs(secs: Int) { - viewModelScope.launch { - settingsManager.setPoiGeofenceResponsivenessSecs(secs) - geofenceOrchestrator.triggerRecalculation() - } + private fun persist(write: suspend () -> Unit) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = {}, action = write) + } + + private fun recalculate(write: suspend () -> Unit) = viewModelScope.launch { + mutate(ERROR_SAVE, onSuccess = geofenceOrchestrator::triggerRecalculation, action = write) + } + + private companion object { + const val ERROR_SAVE = "Could not save the setting" } } diff --git a/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt b/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt index 4a57604..f040ef8 100644 --- a/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt +++ b/app/src/main/java/dev/arrase/geotify/ui/theme/Theme.kt @@ -1,12 +1,18 @@ package dev.arrase.geotify.ui.theme import android.app.Activity +import android.content.Context +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -17,19 +23,16 @@ fun GeotifyTheme( content: @Composable () -> Unit ) { val context = LocalContext.current - val colorScheme = when { - android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> androidx.compose.material3.darkColorScheme() - else -> androidx.compose.material3.lightColorScheme() - } + val colorScheme = rememberColorScheme(context, darkTheme) val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + (view.context as? Activity)?.window?.let { window -> + val controller = WindowCompat.getInsetsController(window, view) + controller.isAppearanceLightStatusBars = !darkTheme + controller.isAppearanceLightNavigationBars = !darkTheme + } } } @@ -39,3 +42,14 @@ fun GeotifyTheme( content = content ) } + +/** Uses the wallpaper-derived palette on Android 12+, falling back to the baseline schemes. */ +@Composable +private fun rememberColorScheme(context: Context, darkTheme: Boolean): ColorScheme { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return if (darkTheme) darkColorScheme() else lightColorScheme() + } + return remember(context, darkTheme) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } +} diff --git a/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt b/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt new file mode 100644 index 0000000..306760f --- /dev/null +++ b/app/src/main/java/dev/arrase/geotify/ui/theme/ThemeSettingExt.kt @@ -0,0 +1,10 @@ +package dev.arrase.geotify.ui.theme + +import dev.arrase.geotify.data.ThemeSetting + +/** Resolves a [ThemeSetting] into a concrete boolean for a feature that only has light/dark. */ +fun ThemeSetting.resolve(systemIsDark: Boolean): Boolean = when (this) { + ThemeSetting.SYSTEM -> systemIsDark + ThemeSetting.LIGHT -> false + ThemeSetting.DARK -> true +} diff --git a/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt b/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt index e5b8702..250b602 100644 --- a/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt +++ b/app/src/main/java/dev/arrase/geotify/util/ContextExtensions.kt @@ -4,29 +4,33 @@ import android.content.BroadcastReceiver import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlin.time.Duration.Companion.seconds /** - * Calls [goAsync] and launches a coroutine to perform background work within the - * BroadcastReceiver's lifecycle. Ensures [PendingResult.finish] is always called, - * even if the coroutine fails or times out. + * Runs [block] with the BroadcastReceiver kept alive via [goAsync], guaranteeing that + * `PendingResult.finish()` is always called. * - * Android kills BroadcastReceivers after ~10 seconds, so a 9-second timeout is enforced. + * Android only grants receivers about 10 seconds, so the work is bounded by a slightly shorter + * timeout. On timeout the coroutine is cancelled and the block unwinds, so any cleanup it still + * needs must happen in a `finally` block. */ -fun BroadcastReceiver.goAsyncCoroutine(block: suspend CoroutineScope.() -> Unit) { +fun BroadcastReceiver.goAsyncCoroutine(block: suspend () -> Unit) { val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + CoroutineScope(Dispatchers.IO).launch { try { - withTimeout(9.seconds) { - block() - } + withTimeout(TIMEOUT) { block() } + } catch (e: TimeoutCancellationException) { + Log.e(TAG, "BroadcastReceiver coroutine timed out after $TIMEOUT", e) } catch (e: Exception) { - Log.e("GoAsyncCoroutine", "Error in BroadcastReceiver coroutine", e) + Log.e(TAG, "BroadcastReceiver coroutine failed", e) } finally { pendingResult.finish() } } } + +private const val TAG = "GoAsyncCoroutine" +private val TIMEOUT = 9.seconds diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a2ac373..04578d0 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -6,7 +6,6 @@ حفظ تخطي فتح الإعدادات - استخدام إحداثيات GPS الحالية جاري استعلام GPS… المواقع التذكيرات @@ -36,12 +35,8 @@ تعديل الموقع تذكير جديد تعديل التذكير - تم حذف \"%1$s\" - تم إلغاء التذكير - تم حذف التذكير إضافة موقع إضافة تذكير - موقع تذكير إلغاء اختر الموقع @@ -63,7 +58,6 @@ الخريطة اختر على الخريطة استخدم GPS - محدد الخريطة اختر الموقع على الخريطة الموقع المحدد تأكيد الاختيار @@ -78,10 +72,6 @@ النظام فاتح داكن - تم الوصول إلى حد السياج الجغرافي (الحد الأقصى 100). لا يمكنك تفعيل المزيد. - لقد وصلت إلى الحد الأقصى وهو 100 سياج جغرافي نشط. - تم الوصول إلى حد السياج الجغرافي البالغ 100. يمكنك فقط إضافة تذكيرات للمواقع ذات السياجات النشطة. - تم الوصول إلى حد السياج الجغرافي البالغ 100. يرجى اختيار موقع به تذكيرات نشطة. استجابة الإشعارات %1$d دقيقة 0 دقيقة (فوري) @@ -116,4 +106,19 @@ رجوع التركيز على نظام تحديد المواقع + لم يعد هذا الموقع موجودًا + %1$s: %2$.5f، %3$s: %4$.5f + ‎%1$.5f، %2$.5f + + %d تذكير نشط + %d تذكير نشط + %d تذكيران نشطان + %d تذكيرات نشطة + %d تذكيرًا نشطًا + %d تذكير نشط + + تم حذف "%1$s" + تم إلغاء التذكير + تم حذف التذكير + غير محدد \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7dd2fdc..45416d0 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -6,7 +6,6 @@ Uložit Přeskočit Otevřít nastavení - Použít aktuální GPS souřadnice Dotazování GPS… Lokalizace Upomínky @@ -36,12 +35,8 @@ Upravit lokalizaci Nová upomínka Upravit upomínku - \"%1$s\" smazáno - Upomínka zrušena - Upomínka smazána Přidat lokalizaci Přidat upomínku - Lokalizace Upomínka Zrušit Vybrat lokalizaci @@ -63,7 +58,6 @@ Mapa Vybrat na mapě Použít GPS - Výběr mapy Vybrat lokalizaci na mapě Vybraná lokalizace Potvrdit výběr @@ -78,10 +72,6 @@ Systémové Světlé Tmavé - Dosáhnuto omezení geofencingu (maximálně 100). Nemůžete jej aktivovat více. - Dosáhli jste maximálního limitu 100 aktivních geofencingů. - Dosáhnuto omezení geofencingu 100. Můžete přidávat upomínky pouze k lokalizacím s aktivními geofencingy. - Dosáhnuto omezení geofencingu 100. Vyberte lokalizaci s aktivní upomínkou. Reaktivnost oznámení %1$d min 0 min (okamžitě) @@ -116,4 +106,17 @@ Zpět Vycentrovat na GPS + Tato lokalizace již neexistuje + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktivní připomínka + %d aktivní připomínky + %d aktivních připomínek + %d aktivní připomínky + + "%1$s" smazáno + Připomínka zrušena + Připomínka smazána + Neznámé \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index aac89cb..9d5bdcc 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -6,7 +6,6 @@ Gem Spring over Åbn indstillinger - Brug nuværende GPS-koordinater Henter GPS… Lokationer Påmindelser @@ -36,12 +35,8 @@ Rediger lokation Ny påmindelse Rediger påmindelse - \"%1$s\" slettet - Påmindelse annulleret - Påmindelse slettet Tilføj lokation Tilføj påmindelse - Lokation Påmindelse Annuller Vælg lokation @@ -63,7 +58,6 @@ Kort Vælg på kort Brug GPS - Kortvælger Vælg lokation på kort Valgt lokation Bekræft valg @@ -78,10 +72,6 @@ System Lyst Mørkt - Geofence-grænse nået (maksimalt 100). Du kan ikke aktivere flere. - Du har nået den maksimale grænse på 100 aktive geofences. - Geofence-grænse på 100 nået. Du kan kun tilføje påmindelser til lokationer med aktive geofences. - Geofence-grænse på 100 nået. Vælg venligst en lokation med aktive påmindelser. Notifikationsrespons %1$d min 0 min (Øjeblikkelig) @@ -116,4 +106,15 @@ Tilbage Centrer på GPS + Denne placering findes ikke længere + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktiv påmindelse + %d aktive påmindelser + + "%1$s" slettet + Påmindelse annulleret + Påmindelse slettet + Ukendt \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 987db92..599d1f1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -6,7 +6,6 @@ Speichern Überspringen Einstellungen öffnen - Aktuelle GPS-Koordinaten nutzen GPS-Abfrage… Orte Erinnerungen @@ -36,12 +35,8 @@ Ort bearbeiten Neue Erinnerung Erinnerung bearbeiten - „%1$s“ gelöscht - Erinnerung abgebrochen - Erinnerung gelöscht Ort hinzufügen Erinnerung hinzufügen - Ort Erinnerung Abbrechen Ort auswählen @@ -63,7 +58,6 @@ Karte Auf Karte auswählen GPS nutzen - Kartenauswahl Ort auf Karte auswählen Ausgewählter Ort Auswahl bestätigen @@ -78,10 +72,6 @@ System Hell Dunkel - Geofence-Limit erreicht (maximal 100). Sie können keine weiteren aktivieren. - Sie haben das maximale Limit von 100 aktiven Geofences erreicht. - Geofence-Limit von 100 erreicht. Sie können Erinnerungen nur für Orte mit aktiven Geofences hinzufügen. - Geofence-Limit von 100 erreicht. Bitte wählen Sie einen Ort mit aktiven Erinnerungen. Reaktionszeit %1$d Min. 0 Min. (Sofort) @@ -114,4 +104,15 @@ Mein Standort Zurück Auf GPS zentrieren + Dieser Ort existiert nicht mehr + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktive Erinnerung + %d aktive Erinnerungen + + "%1$s" gelöscht + Erinnerung abgebrochen + Erinnerung gelöscht + Unbekannt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e0ca59f..817b9b8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -6,7 +6,6 @@ Guardar Omitir Abrir ajustes - Usar coordenadas GPS actuales Obteniendo GPS… Lugares Recordatorios @@ -36,12 +35,8 @@ Editar lugar Nuevo recordatorio Editar recordatorio - \"%1$s\" eliminado - Recordatorio cancelado - Recordatorio eliminado Añadir lugar Añadir recordatorio - Ubicación Recordatorio Cancelar Seleccionar lugar @@ -63,7 +58,6 @@ Mapa Seleccionar en mapa Usar GPS - Selector de mapa Seleccionar ubicación en el mapa Ubicación seleccionada Confirmar selección @@ -78,10 +72,6 @@ Sistema Claro Oscuro - Límite de geovallas alcanzado (máximo 100). No puedes activar más. - Has alcanzado el límite máximo de 100 geovallas activas. - Límite de 100 geovallas alcanzado. Solo puedes añadir recordatorios a ubicaciones con geovallas activas. - Límite de 100 geovallas alcanzado. Selecciona una ubicación con recordatorios activos. Tiempo de respuesta %1$d min 0 min (Instantáneo) @@ -114,4 +104,16 @@ Mi ubicación Atrás Centrar en GPS + Esa ubicación ya no existe + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d recordatorio activo + %d de recordatorios activos + %d recordatorios activos + + "%1$s" eliminado + Recordatorio cancelado + Recordatorio eliminado + Desconocido diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8f257fe..8ec7a08 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -6,7 +6,6 @@ Enregistrer Ignorer Ouvrir les paramètres - Utiliser les coordonnées GPS actuelles Recherche GPS… Lieux Rappels @@ -36,12 +35,8 @@ Modifier le lieu Nouveau rappel Modifier le rappel - \"%1$s\" supprimé - Rappel annulé - Rappel supprimé Ajouter un lieu Ajouter un rappel - Lieu Rappel Annuler Sélectionner un lieu @@ -63,7 +58,6 @@ Carte Sélectionner sur la carte Utiliser le GPS - Sélecteur de carte Sélectionner un lieu sur la carte Lieu sélectionné Confirmer la sélection @@ -78,10 +72,6 @@ Système Clair Sombre - Limite de géobarrières atteinte (maximum 100). Vous ne pouvez pas en activer plus. - Vous avez atteint la limite maximale de 100 géobarrières actives. - Limite de 100 géobarrières atteinte. Vous ne pouvez ajouter des rappels que pour les lieux ayant des géobarrières actives. - Limite de 100 géobarrières atteinte. Veuillez sélectionner un lieu avec des rappels actifs. Réactivité des notifications %1$d min 0 min (Instantané) @@ -114,4 +104,16 @@ Ma position Retour Centrer sur le GPS + Ce lieu n\'existe plus + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d rappel actif + %d de rappels actifs + %d rappels actifs + + "%1$s" supprimé + Rappel annulé + Rappel supprimé + Inconnu diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index bbacea1..15159ac 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -6,7 +6,6 @@ सहेजें छोड़ें सेटिंग्स खोलें - वर्तमान GPS निर्देशांक का उपयोग करें GPS क्वेरी कर रहा है… स्थान रिमाइंडर @@ -36,12 +35,8 @@ स्थान संपादित करें नया रिमाइंडर रिमाइंडर संपादित करें - \"%1$s\" हटा दिया गया - रिमाइंडर रद्द कर दिया गया - रिमाइंडर हटा दिया गया स्थान जोड़ें रिमाइंडर जोड़ें - स्थान रिमाइंडर रद्द करें स्थान चुनें @@ -63,7 +58,6 @@ मानचित्र मानचित्र पर चुनें GPS का उपयोग करें - मैप पिकर मानचित्र पर स्थान चुनें चयनित स्थान चयन की पुष्टि करें @@ -78,10 +72,6 @@ सिस्टम लाइट डार्क - जियोफेंस सीमा तक पहुंच गई है (अधिकतम 100)। आप और सक्रिय नहीं कर सकते। - आप सक्रिय जियोफेंस की अधिकतम सीमा 100 तक पहुंच गए हैं। - जियोफेंस की सीमा 100 तक पहुंच गई है। आप केवल सक्रिय जियोफेंस वाले स्थानों पर रिमाइंडर जोड़ सकते हैं। - जियोफेंस की सीमा 100 तक पहुंच गई है। कृपया सक्रिय रिमाइंडर वाला स्थान चुनें। नोटिफिकेशन प्रतिक्रियाशीलता %1$d मिनट 0 मिनट (तत्काल) @@ -116,4 +106,15 @@ पीछे जीपीएस पर केंद्रित करें + वह स्थान अब मौजूद नहीं है + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d सक्रिय अनुस्मारक + %d सक्रिय अनुस्मारक + + "%1$s" हटाया गया + अनुस्मारक रद्द किया गया + अनुस्मारक हटाया गया + अज्ञात \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 4c71616..00eeb05 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -6,7 +6,6 @@ Mentés Ugrás Beállítások megnyitása - Aktuális GPS koordináták használata GPS lekérdezés… Helyszínek Emlékeztetők @@ -36,12 +35,8 @@ Helyszín szerkesztése Új emlékeztető Emlékeztető szerkesztése - \"%1$s\" törölve - Emlékeztető megállítva - Emlékeztető törölve Helyszín hozzáadása Emlékeztető hozzáadása - Helyszín Emlékeztető Mégse Helyszín választása @@ -63,7 +58,6 @@ Térkép Választás térképen GPS használata - Térképválogató Helyszín választása térképen Kiválasztott helyszín Választás megerősítése @@ -78,10 +72,6 @@ Rendszer Világos Sötét - Elért a geofence korlát (maximum 100). Nem tud többet aktiválni. - Elért az aktív geofencek maximum 100-es korlátja. - Elért a geofence korlát 100. Csak olyan helyszínekhez adhat emlékeztetőt, amelyek aktív geofencenekkel rendelkeznek. - Elért a geofence korlát 100. Kérjük, válassza ki egy olyan helyszínt, amelyben aktív emlékeztető van. Értesítés válaszideje %1$d perc 0 perc (azonnali) @@ -116,4 +106,15 @@ Vissza Középpontba helyezés GPS alapján + Ez a hely már nem létezik + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktív emlékeztető + %d aktív emlékeztető + + "%1$s" törölve + Emlékeztető törölve + Emlékeztető törölve + Ismeretlen \ No newline at end of file diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-in/strings.xml similarity index 90% rename from app/src/main/res/values-id/strings.xml rename to app/src/main/res/values-in/strings.xml index bbabfa0..6c09365 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -6,7 +6,6 @@ Simpan Lewati Buka Pengaturan - Gunakan Koordinat GPS Saat Ini Sedang Mencari GPS… Lokasi Pengingat @@ -36,12 +35,8 @@ Edit Lokasi Pengingat Baru Edit Pengingat - \"%1$s\" dihapus - Pengingat dibatalkan - Pengingat dihapus Tambah Lokasi Tambah Pengingat - Lokasi Pengingat Batal Pilih Lokasi @@ -63,7 +58,6 @@ Peta Pilih di Peta Gunakan GPS - Pemilih Peta Pilih Lokasi di Peta Lokasi Terpilih Konfirmasi Pilihan @@ -78,10 +72,6 @@ Sistem Terang Gelap - Batas geofence tercapai (maksimal 100). Anda tidak dapat mengaktifkan lebih banyak lagi. - Anda telah mencapai batas maksimum 100 geofence aktif. - Batas geofence 100 tercapai. Anda hanya dapat menambahkan pengingat ke lokasi dengan geofence aktif. - Batas geofence 100 tercapai. Harap pilih lokasi dengan pengingat aktif. Responsivitas Notifikasi %1$d menit 0 menit (Instan) @@ -116,4 +106,14 @@ Kembali Pusatkan pada GPS + Lokasi tersebut tidak ada lagi + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d pengingat aktif + + "%1$s" dihapus + Pengingat dibatalkan + Pengingat dihapus + Tidak diketahui \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 12585af..e3ef0cb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -6,7 +6,6 @@ Salva Ignora Apri impostazioni - Usa coordinate GPS correnti Rilevamento GPS… Luoghi Promemoria @@ -36,12 +35,8 @@ Modifica luogo Nuovo promemoria Modifica promemoria - \"%1$s\" eliminato - Promemoria annullato - Promemoria eliminato Aggiungi luogo Aggiungi promemoria - Posizione Promemoria Annulla Seleziona luogo @@ -63,7 +58,6 @@ Mappa Seleziona sulla mappa Usa GPS - Selettore mappa Seleziona posizione sulla mappa Posizione selezionata Conferma selezione @@ -78,10 +72,6 @@ Sistema Chiaro Scuro - Limite di geofence raggiunto (massimo 100). Non puoi attivarne altri. - Hai raggiunto il limite massimo di 100 geofence attivi. - Limite di 100 geofence raggiunto. Puoi solo aggiungere promemoria a luoghi con geofence attivi. - Limite di 100 geofence raggiunto. Seleziona un luogo con promemoria attivi. Reattività delle notifiche %1$d min 0 min (Istantaneo) @@ -114,4 +104,16 @@ La mia posizione Indietro Centra su GPS + Questa posizione non esiste più + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d promemoria attivo + %d di promemoria attivi + %d promemoria attivi + + "%1$s" eliminato + Promemoria annullato + Promemoria eliminato + Sconosciuto diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 34bab82..ed485cd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -6,7 +6,6 @@ 保存 スキップ 設定を開く - 現在のGPS座標を使用 GPSを照会中… 場所 リマインダー @@ -36,12 +35,8 @@ 場所を編集 新しいリマインダー リマインダーを編集 - \"%1$s\" を削除しました - リマインダーをキャンセルしました - リマインダーを削除しました 場所を追加 リマインダーを追加 - 場所 リマインダー キャンセル 場所を選択 @@ -63,7 +58,6 @@ 地図 地図上で選択 GPSを使用 - マップピッカー 地図上の場所を選択 選択された場所 選択を確定 @@ -78,10 +72,6 @@ システム ライト ダーク - ジオフェンスの制限に達しました(最大100)。これ以上アクティブにできません。 - アクティブなジオフェンスの最大制限100に達しました。 - ジオフェンスの制限(100)に達しました。アクティブなジオフェンスがある場所のみリマインダーを追加できます。 - ジオフェンスの制限(100)に達しました。アクティブなリマインダーがある場所を選択してください。 通知の応答性 %1$d 分 0 分(即時) @@ -116,4 +106,14 @@ 戻る GPS位置に合わせる + その場所は存在しません + %1$s: %2$.5f、%3$s: %4$.5f + %1$.5f、%2$.5f + + 有効なリマインダー %d 件 + + 「%1$s」を削除しました + リマインダーをキャンセルしました + リマインダーを削除しました + 不明 \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8e17d0d..906186a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -6,7 +6,6 @@ 저장 건너뛰기 설정 열기 - 현재 GPS 좌표 사용 GPS 조회 중… 장소 리마인더 @@ -36,12 +35,8 @@ 장소 편집 새 리마인더 리마인더 편집 - \"%1$s\" 삭제됨 - 리마인더 취소됨 - 리마인더 삭제됨 장소 추가 리마인더 추가 - 장소 리마인더 취소 장소 선택 @@ -63,7 +58,6 @@ 지도 지도에서 선택 GPS 사용 - 지도 피커 지도에서 장소 선택 선택된 장소 선택 확인 @@ -78,10 +72,6 @@ 시스템 라이트 다크 - 지오펜스 제한에 도달했습니다 (최대 100개). 더 이상 활성화할 수 없습니다. - 활성 지오펜스 최대 제한인 100개에 도달했습니다. - 지오펜스 제한 100개에 도달했습니다. 활성 지오펜스가 있는 장소에만 리마인더를 추가할 수 있습니다. - 지오펜스 제한 100개에 도달했습니다. 활성 리마인더가 있는 장소를 선택해 주세요. 알림 응답성 %1$d 분 0 분 (즉시) @@ -116,4 +106,14 @@ 뒤로 GPS 기준으로 정렬 + 해당 위치가 더 이상 존재하지 않습니다 + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + 활성 알림 %d개 + + "%1$s" 삭제됨 + 알림 취소됨 + 알림이 삭제되었습니다 + 알 수 없음 \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a2d74d5..d23ff8f 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -6,7 +6,6 @@ Opslaan Overslaan Instellingen openen - Gebruik huidige GPS-coördinaten GPS opvragen… Locaties Herinneringen @@ -36,12 +35,8 @@ Locatie bewerken Nieuwe herinnering Herinnering bewerken - \"%1$s\" verwijderd - Herinnering geannuleerd - Herinnering verwijderd Locatie toevoegen Herinnering toevoegen - Locatie Herinnering Annuleren Locatie selecteren @@ -63,7 +58,6 @@ Kaart Selecteren op kaart GPS gebruiken - Kaartkiezer Locatie selecteren op kaart Geselecteerde locatie Selectie bevestigen @@ -78,10 +72,6 @@ Systeem Licht Donker - Geofence-limiet bereikt (maximaal 100). Je kunt er geen meer activeren. - Je hebt de maximale limiet van 100 actieve geofences bereikt. - Geofence-limiet van 100 bereikt. Je kunt alleen herinneringen toevoegen aan locaties met actieve geofences. - Geofence-limiet van 100 bereikt. Selecteer een locatie met actieve herinneringen. Meldingsresponsiviteit %1$d min 0 min (Direct) @@ -116,4 +106,15 @@ Terug Centreren op GPS + Deze locatie bestaat niet meer + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d actieve herinnering + %d actieve herinneringen + + "%1$s" verwijderd + Herinnering geannuleerd + Herinnering verwijderd + Onbekend \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dfbce7c..89d217d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -6,7 +6,6 @@ Zapisz Pomiń Otwórz ustawienia - Użyj aktualnych współrzędnych GPS Pytanie o GPS… Lokalizacje Przypomnienia @@ -36,12 +35,8 @@ Edytuj lokalizację Nowe przypomnienie Edytuj przypomnienie - \"%1$s\" usunięte - Przypomnienie anulowane - Przypomnienie usunięte Dodaj lokalizację Dodaj przypomnienie - Lokalizacja Przypomnienie Anuluj Wybierz lokalizację @@ -63,7 +58,6 @@ Mapa Wybierz na mapie Użyj GPS - Wybór mapy Wybierz lokalizację na mapie Wybrana lokalizacja Potwierdź wybór @@ -78,10 +72,6 @@ Systemowy Jasny Ciemny - Osiągnięto limit geofencingu (maksymalnie 100). Nie możesz aktywować więcej. - Osiągnąłeś maksymalny limit 100 aktywnych geofencingów. - Osiągnięto limit geofencingu 100. Możesz dodawać przypomnienia tylko do lokalizacji z aktywnymi geofencingami. - Osiągnięto limit geofencingu 100. Wybierz lokalizację z aktywnymi przypomnieniami. Reaktywność powiadomień %1$d min 0 min (natychmiast) @@ -116,4 +106,17 @@ Wstecz Wyśrodkuj na GPS + Ta lokalizacja już nie istnieje + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktywne przypomnienie + %d aktywne przypomnienia + %d aktywnych przypomnień + %d aktywnego przypomnienia + + Usunięto "%1$s" + Przypomnienie anulowane + Przypomnienie usunięte + Nieznany \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8ce3c93..c405072 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -6,7 +6,6 @@ Salvar Pular Abrir configurações - Usar coordenadas GPS atuais Buscando GPS… Locais Lembretes @@ -36,12 +35,8 @@ Editar local Novo lembrete Editar lembrete - \"%1$s\" excluído - Lembrete cancelado - Lembrete excluído Adicionar local Adicionar lembrete - Localização Lembrete Cancelar Selecionar local @@ -63,7 +58,6 @@ Mapa Selecionar no mapa Usar GPS - Seletor de mapa Selecionar localização no mapa Localização selecionada Confirmar seleção @@ -78,10 +72,6 @@ Sistema Claro Escuro - Limite de geocercas atingido (máximo 100). Você não pode ativar mais. - Você atingiu o limite máximo de 100 geocercas ativas. - Limite de 100 geocercas atingido. Você só pode adicionar lembretes a locais com geocercas ativas. - Limite de 100 geocercas atingido. Selecione um local com lembretes ativos. Tempo de resposta das notificações %1$d min 0 min (Instantâneo) @@ -114,4 +104,16 @@ Minha localização Voltar Centrar no GPS + Essa localização já não existe + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d lembrete ativo + %d de lembretes ativos + %d lembretes ativos + + "%1$s" excluída + Lembrete cancelado + Lembrete excluído + Desconhecido diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 0fa3662..2f3ec29 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -6,7 +6,6 @@ Сохранить Пропустить Открыть настройки - Использовать текущие координаты GPS Запрос GPS… Места Напоминания @@ -36,12 +35,8 @@ Редактировать место Новое напоминание Редактировать напоминание - \"%1$s\" удалено - Напоминание отменено - Напоминание удалено Добавить место Добавить напоминание - Место Напоминание Отмена Выберите место @@ -63,7 +58,6 @@ Карта Выбрать на карте Использовать GPS - Выбор карты Выберите место на карте Выбранное место Подтвердить выбор @@ -78,10 +72,6 @@ Системная Светлая Темная - Достигнут лимит геозон (максимум 100). Невозможно активировать больше. - Вы достигли максимального лимита в 100 активных геозон. - Достигнут лимит геозон в 100. Вы можете добавлять напоминания только к местам с активными геозонами. - Достигнут лимит геозон в 100. Пожалуйста, выберите место с активными напоминаниями. Отклик уведомлений %1$d мин 0 мин (мгновенно) @@ -116,4 +106,17 @@ Назад Центрировать по GPS + Это местоположение больше не существует + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d активное напоминание + %d активных напоминания + %d активных напоминаний + %d активного напоминания + + "%1$s" удалено + Напоминание отменено + Напоминание удалено + Неизвестно \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index df35e50..f8fc97b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -6,7 +6,6 @@ Spara Skippa Öppna inställningar - Använd nuvarande GPS-koordinater Frågar efter GPS… Platser Påminnelser @@ -36,12 +35,8 @@ Redigera plats Ny påminnelse Redigera påminnelse - \"%1$s\" taggen bort - Påminnelsen avbruten - Påminnelsen taggen bort Lägg till plats Lägg till påminnelse - Plats Påminnelse Avbryt Välj plats @@ -63,7 +58,6 @@ Karta Välj på karta Använd GPS - Kartväljare Välj plats på karta Vald plats Bekräfta val @@ -78,10 +72,6 @@ System Ljust Mörkt - Geofence-gräns nådd (max 100). Du kan inte aktivera fler. - Du har nått den maximala gränsen på 100 aktiva geofences. - Geofence-gräns på 100 nådd. Du kan bara lägga till påminnelser i platser med aktiva geofences. - Geofence-gräns på 100 nådd. Välj en plats med aktiva påminnelser. Aviseringsrespons %1$d min 0 min (Direkt) @@ -116,4 +106,15 @@ Tillbaka Centrera på GPS + Den platsen finns inte längre + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d aktiv påminnelse + %d aktiva påminnelser + + "%1$s" borttagen + Påminnelse avbruten + Påminnelse borttagen + Okänd \ No newline at end of file diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f21f4f8..b090cc3 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -6,7 +6,6 @@ บันทึก ข้าม เปิดการตั้งค่า - ใช้พิกัด GPS ปัจจุบัน กำลังสอบถาม GPS… สถานที่ การแจ้งเตือน @@ -36,12 +35,8 @@ แก้ไขสถานที่ การแจ้งเตือนใหม่ แก้ไขการแจ้งเตือน - \"%1$s\" ถูกลบแล้ว - ยกเลิกการแจ้งเตือนแล้ว - ลบการแจ้งเตือนแล้ว เพิ่มสถานที่ เพิ่มการแจ้งเตือน - สถานที่ การแจ้งเตือน ยกเลิก เลือกสถานที่ @@ -63,7 +58,6 @@ แผนที่ เลือกบนแผนที่ ใช้ GPS - ตัวเลือกแผนที่ เลือกสถานที่บนแผนที่ สถานที่ที่เลือก ยืนยันการเลือก @@ -78,10 +72,6 @@ ระบบ สว่าง มืด - ถึงขีดจำกัด Geofence (สูงสุด 100) ไม่สามารถเปิดใช้งานเพิ่มได้ - คุณถึงขีดจำกัดสูงสุดของ Geofence ที่ใช้งานอยู่คือ 100 แล้ว - ถึงขีดจำกัด Geofence 100 คุณสามารถเพิ่มการแจ้งเตือนได้เฉพาะในสถานที่ที่มี Geofence ทำงานอยู่เท่านั้น - ถึงขีดจำกัด Geofence 100 โปรดเลือกสถานที่ที่มีการแจ้งเตือนที่ใช้งานอยู่ ความเร็วในการตอบสนองของการแจ้งเตือน %1$d นาที 0 นาที (ทันที) @@ -116,4 +106,14 @@ ย้อนกลับ จัดกึ่งกลางตาม GPS + ไม่พบสถานที่นี้แล้ว + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + การแจ้งเตือนที่ใช้งานอยู่ %d รายการ + + ลบ "%1$s" แล้ว + ยกเลิกการแจ้งเตือนแล้ว + ลบการแจ้งเตือนแล้ว + ไม่ทราบ \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 61872f8..db3c544 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -6,7 +6,6 @@ Kaydet Atla Ayarları Aç - Mevcut GPS Koordinatlarını Kullan GPS Sorgulanıyor… Konumlar Hatırlatıcılar @@ -36,12 +35,8 @@ Konumu Düzenle Yeni Hatırlatıcı Hatırlatıcıyı Düzenle - \"%1$s\" silindi - Hatırlatıcı iptal edildi - Hatırlatıcı silindi Konum Ekle Hatırlatıcı Ekle - Konum Hatırlatıcı İptal Konum Seçin @@ -63,7 +58,6 @@ Harita Haritada Seç GPS Kullan - Harita Seçici Haritadan Konum Seçin Seçilen Konum Seçimi Onayla @@ -78,10 +72,6 @@ Sistem Açık Koyu - Geofence sınırı aşıldı (maksimum 100). Daha fazlasını aktif edemezsiniz. - Aktif geofence sayınız maksimum sınır olan 100\'e ulaştı. - Geofence sınırı 100\'e ulaşıldı. Sadece aktif geofence\'i olan konumlara hatırlatıcı ekleyebilirsiniz. - Geofence sınırı 100\'e ulaşıldı. Lütfen aktif hatırlatıcısı olan bir konum seçin. Bildirim Yanıt Süresi %1$d dk 0 dk (Anında) @@ -116,4 +106,15 @@ Geri GPS üzerinde merkezle + Bu konum artık mevcut değil + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d etkin hatırlatıcı + %d etkin hatırlatıcı + + "%1$s" silindi + Hatırlatıcı iptal edildi + Hatırlatıcı silindi + Bilinmiyor \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index bdec4e5..fc3408a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -6,7 +6,6 @@ Lưu Bỏ qua Mở Cài đặt - Sử dụng Tọa độ GPS Hiện tại Đang truy vấn GPS… Địa điểm Nhắc nhở @@ -36,12 +35,8 @@ Chỉnh sửa địa điểm Nhắc nhở mới Chỉnh sửa nhắc nhở - \"%1$s\" đã bị xóa - Nhắc nhở đã bị hủy - Nhắc nhở đã bị xóa Thêm địa điểm Thêm nhắc nhở - Địa điểm Nhắc nhở Hủy Chọn địa điểm @@ -63,7 +58,6 @@ Bản đồ Chọn trên bản đồ Sử dụng GPS - Bộ chọn bản đồ Chọn địa điểm trên bản đồ Địa điểm đã chọn Xác nhận lựa chọn @@ -78,10 +72,6 @@ Hệ thống Sáng Tối - Đã đạt giới hạn hàng rào địa lý (tối đa 100). Không thể kích hoạt thêm. - Bạn đã đạt giới hạn tối đa là 100 hàng rào địa lý đang hoạt động. - Đã đạt giới hạn hàng rào địa lý là 100. Bạn chỉ có thể thêm nhắc nhở vào các địa điểm có hàng rào địa lý đang hoạt động. - Đã đạt giới hạn hàng rào địa lý là 100. Vui lòng chọn một địa điểm có nhắc nhở đang hoạt động. Độ phản hồi thông báo %1$d phút 0 phút (Tức thì) @@ -116,4 +106,14 @@ Quay lại Căn giữa theo GPS + Địa điểm đó không còn tồn tại + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d lời nhắc đang hoạt động + + Đã xóa "%1$s" + Đã hủy lời nhắc + Đã xóa lời nhắc + Không xác định \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e35db0c..43d999d 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -6,7 +6,6 @@ 保存 跳过 打开设置 - 使用当前 GPS 坐标 正在查询 GPS… 位置 提醒 @@ -36,12 +35,8 @@ 编辑位置 新提醒 编辑提醒 - \"%1$s\" 已删除 - 提醒已取消 - 提醒已删除 添加位置 添加提醒 - 位置 提醒 取消 选择位置 @@ -63,7 +58,6 @@ 地图 在地图上选择 使用 GPS - 地图选择器 在地图上选择位置 已选位置 确认选择 @@ -78,10 +72,6 @@ 系统 浅色 深色 - 达到地理围栏限制(最大 100 个)。无法激活更多。 - 您已达到 100 个活动地理围栏的最大限制。 - 达到 100 个地理围栏限制。您只能向具有活动地理围栏的位置添加提醒。 - 达到 100 个地理围栏限制。请选择具有活动提醒的位置。 通知响应速度 %1$d 分钟 0 分钟(即时) @@ -116,4 +106,14 @@ 返回 对齐到GPS + 该位置已不存在 + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d 个活动提醒 + + 已删除“%1$s” + 已取消提醒 + 已删除提醒 + 未知 \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 432652d..b7700e4 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,11 +1,4 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF #2489D9 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a26b2..fbd0cd3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ Save Skip Open Settings - Use Current GPS Coordinates Querying GPS… Locations Reminders @@ -36,12 +35,8 @@ Edit Location New Reminder Edit Reminder - \"%1$s\" deleted - Reminder cancelled - Reminder deleted Add Location Add Reminder - Location Reminder Cancel Select Location @@ -57,18 +52,24 @@ Geofence Reminders Notifications triggered when you enter or leave a saved location Failed to obtain GPS coordinates. Please make sure location services are enabled. + That location no longer exists Background location permission is missing. Geofences will not trigger until you grant \"Allow all the time\" permission. Fix List Map Select on Map Use GPS - Map Picker Select Location on Map Selected Location Confirm Selection Latitude Longitude + %1$s: %2$.5f, %3$s: %4$.5f + %1$.5f, %2$.5f + + %d active reminder + %d active reminders + Settings App Theme Choose whether the application uses light, dark, or system theme. @@ -78,10 +79,6 @@ System Light Dark - Geofence limit reached (maximum 100). You cannot activate more. - You have reached the maximum limit of 100 active geofences. - Geofence limit of 100 reached. You can only add reminders to locations with active geofences. - Geofence limit of 100 reached. Please select a location with active reminders. Notification Responsiveness %1$d min 0 min (Instant) @@ -114,4 +111,8 @@ My Location Back Center on GPS + "%1$s" deleted + Reminder cancelled + Reminder deleted + Unknown \ No newline at end of file diff --git a/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt b/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt deleted file mode 100644 index 379047f..0000000 --- a/app/src/test/java/dev/arrase/geotify/GeofenceLimitTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package dev.arrase.geotify - -import dev.arrase.geotify.data.ReminderRepository -import dev.arrase.geotify.data.dao.ReminderDao -import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.data.entity.LocationReminderCount -import dev.arrase.geotify.data.entity.ReminderEntity -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test - -class GeofenceLimitTest { - - private val locationsList = mutableListOf() - private val remindersList = mutableListOf() - - private val fakeReminderDao = object : ReminderDao { - override fun observeActiveReminderCounts(): Flow> = flowOf(emptyList()) - override fun observeAll(): Flow> = flowOf(remindersList) - override suspend fun getActiveReminders(): List = - remindersList.filter { it.isActive } - - override suspend fun getActiveByLocationId(locationId: String): List = - remindersList.filter { it.locationId == locationId && it.isActive } - - override suspend fun insert(reminder: ReminderEntity) { - remindersList.add(reminder) - } - override suspend fun update(reminder: ReminderEntity) { - val idx = remindersList.indexOfFirst { it.id == reminder.id } - if (idx != -1) remindersList[idx] = reminder - } - override suspend fun deactivate(id: String) { - val idx = remindersList.indexOfFirst { it.id == id } - if (idx != -1) { - remindersList[idx] = remindersList[idx].copy(isActive = false) - } - } - override suspend fun deleteById(id: String): Int { - val count = remindersList.size - remindersList.removeAll { it.id == id } - return count - remindersList.size - } - - override suspend fun getActiveGeofenceCount(): Int = - remindersList.filter { it.isActive }.map { it.locationId }.distinct().size - override suspend fun clearAllInRange() {} - override suspend fun setInRangeForLocations(locationIds: List) {} - } - - private val reminderRepository = ReminderRepository( - fakeReminderDao, - kotlinx.coroutines.Dispatchers.Unconfined - ) - - @Test - fun testCreateManyRemindersWithoutLimit() = runBlocking { - locationsList.clear() - remindersList.clear() - - val locations = (1..150).map { i -> - LocationEntity( - id = "loc_$i", - alias = "Location $i", - latitude = 0.0, - longitude = 0.0, - radiusMeters = 100f - ).also { locationsList.add(it) } - } - - for (i in 0 until 150) { - val reminder = reminderRepository.createReminder(locations[i], "Reminder $i", 1) - assertNotNull(reminder) - } - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - } - - @Test - fun testDeactivateAndUpdateReminder() = runBlocking { - locationsList.clear() - remindersList.clear() - - val locations = (1..150).map { i -> - LocationEntity( - id = "loc_$i", - alias = "Location $i", - latitude = 0.0, - longitude = 0.0, - radiusMeters = 100f - ).also { locationsList.add(it) } - } - - for (i in 0 until 149) { - reminderRepository.createReminder(locations[i], "Reminder $i", 1) - } - - val reminder150 = reminderRepository.createReminder(locations[149], "Reminder 150", 1) - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - - reminderRepository.deactivateReminder(reminder150.id) - assertEquals(149, fakeReminderDao.getActiveGeofenceCount()) - - reminderRepository.updateReminder(reminder150.copy(isActive = true)) - assertEquals(150, fakeReminderDao.getActiveGeofenceCount()) - } -} diff --git a/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt b/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt index e34c1ef..dbf237f 100644 --- a/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt +++ b/app/src/test/java/dev/arrase/geotify/appfunction/GeotifyAppFunctionsTest.kt @@ -8,6 +8,7 @@ import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals @@ -47,6 +48,7 @@ class GeotifyAppFunctionsTest { private val locationRepository: LocationRepository = mock() private val reminderRepository: ReminderRepository = mock() private val locationProvider: LocationProvider = mock() + private val geofenceOrchestrator: GeofenceOrchestrator = mock() private val appFunctionContext: AppFunctionContext = mock() private lateinit var appFunctions: GeotifyAppFunctions @@ -56,8 +58,14 @@ class GeotifyAppFunctionsTest { appFunctions = GeotifyAppFunctions( locationRepository = locationRepository, reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, locationProvider = locationProvider ) + runBlocking { + // Deletions report the number of affected rows. + whenever(locationRepository.deleteLocation(any())).thenReturn(true) + whenever(reminderRepository.cancelReminder(any())).thenReturn(true) + } } // ── saveCurrentLocation tests ── diff --git a/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt b/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt new file mode 100644 index 0000000..3444806 --- /dev/null +++ b/app/src/test/java/dev/arrase/geotify/data/GeofenceLimitTest.kt @@ -0,0 +1,76 @@ +package dev.arrase.geotify.data + +import com.google.android.gms.location.Geofence +import dev.arrase.geotify.data.dao.ReminderDao +import dev.arrase.geotify.data.entity.LocationEntity +import dev.arrase.geotify.data.entity.ReminderEntity +import dev.arrase.geotify.domain.SpatialSearchUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * The app never blocks reminder creation; instead the geofence window registered with Play services + * is capped, and only the closest locations are monitored. These tests pin that budget down so it + * cannot drift past the GMS limit of 100 geofences per app. + */ +class GeofenceLimitTest { + + private val reminderDao: ReminderDao = mock() + private val repository = ReminderRepository(reminderDao, Dispatchers.Unconfined) + + private val locations = (1..150).map { index -> + LocationEntity( + id = "loc_$index", + alias = "Location $index", + latitude = 0.0, + longitude = 0.0, + radiusMeters = 100f + ) + } + + @Test + fun `geofence budget leaves exactly one slot for the master geofence`() { + assertEquals(100, SpatialSearchUseCase.MAX_POI_GEOFENCES + 1) + } + + @Test + fun `creating more reminders than the budget is allowed and tracked`() = runBlocking { + val created = locations.mapIndexed { index, location -> + repository.createReminder(location, "Reminder $index", Geofence.GEOFENCE_TRANSITION_ENTER) + } + + assertEquals(150, created.size) + assertTrue(created.size > SpatialSearchUseCase.MAX_POI_GEOFENCES) + created.forEach { verify(reminderDao).insert(it) } + } + + @Test + fun `deactivating and reactivating a reminder round-trips its state`() = runBlocking { + val reminder = repository.createReminder( + locations.first(), + "Buy milk", + Geofence.GEOFENCE_TRANSITION_ENTER + ) + + repository.deactivateReminder(reminder.id) + verify(reminderDao).deactivate(reminder.id) + + val reactivated: ReminderEntity = reminder.copy(isActive = true) + repository.updateReminder(reactivated) + verify(reminderDao).update(reactivated) + } + + @Test + fun `getActiveReminders returns exactly what the dao reports`() = runBlocking { + val active = listOf(ReminderEntity("r1", "loc_1", "Msg", 1, true, 0L)) + whenever(reminderDao.getActiveReminders()).thenReturn(active) + + assertEquals(active, repository.getActiveReminders()) + } +} diff --git a/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt b/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt index bfeaa42..ecc8fcd 100644 --- a/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/LocationRepositoryTest.kt @@ -7,9 +7,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -29,6 +31,11 @@ class LocationRepositoryTest { locationDao = locationDao, ioDispatcher = Dispatchers.Unconfined ) + runBlocking { + // Room returns the new row id, or -1 when the unique alias conflicts. + whenever(locationDao.insert(any())).thenReturn(1L) + whenever(locationDao.deleteByAlias(any())).thenReturn(1) + } } @Test @@ -69,6 +76,44 @@ class LocationRepositoryTest { } } + @Test + fun saveLocation_trimsTheAlias() { + runBlocking { + val result = repository.saveLocation(" Home ", 40.0, -3.0) + + assertEquals("Home", result.alias) + } + } + + @Test + fun saveLocation_rejectsABlankAlias() { + assertThrows(IllegalArgumentException::class.java) { + runBlocking { repository.saveLocation(" ", 40.0, -3.0) } + } + } + + @Test + fun saveLocation_throwsDuplicateAliasExceptionWhenTheAliasIsTaken() { + runBlocking { whenever(locationDao.insert(any())).thenReturn(-1L) } + + val error = assertThrows(DuplicateAliasException::class.java) { + runBlocking { repository.saveLocation("Home", 40.0, -3.0) } + } + assertTrue(error.message!!.contains("Home")) + } + + @Test + fun updateLocation_rejectsNegativeNotificationResponsiveness() { + val location = LocationEntity( + id = "1", alias = "Home", latitude = 40.0, longitude = -3.0, + notificationResponsivenessMs = -1 + ) + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { repository.updateLocation(location) } + } + } + @Test fun saveLocation_withLatitudeBelowMinimum_throwsIllegalArgumentException() { assertThrows(IllegalArgumentException::class.java) { @@ -277,12 +322,31 @@ class LocationRepositoryTest { @Test fun deleteLocation_callsDaoDeleteByAlias() { runBlocking { - repository.deleteLocation("OldPlace") + assertTrue(repository.deleteLocation("OldPlace")) verify(locationDao).deleteByAlias("OldPlace") } } + @Test + fun deleteLocation_reportsWhenNothingWasRemoved() { + runBlocking { whenever(locationDao.deleteByAlias(any())).thenReturn(0) } + + runBlocking { assertFalse(repository.deleteLocation("Missing")) } + } + + @Test + fun findLocationsInBoundingBox_delegatesToDao() { + runBlocking { + val matches = listOf(LocationEntity("1", "Near", 0.0, 0.0)) + whenever( + locationDao.getLocationsInBoundingBox(1.0, 2.0, 3.0, 4.0) + ).thenReturn(matches) + + assertEquals(matches, repository.findLocationsInBoundingBox(1.0, 2.0, 3.0, 4.0)) + } + } + @Test fun observeLocations_delegatesToDaoObserveAll() { runBlocking { diff --git a/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt b/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt index ffd2ed3..a013d1c 100644 --- a/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/ReminderRepositoryTest.kt @@ -1,5 +1,6 @@ package dev.arrase.geotify.data +import com.google.android.gms.location.Geofence import dev.arrase.geotify.data.dao.ReminderDao import dev.arrase.geotify.data.entity.LocationEntity import dev.arrase.geotify.data.entity.LocationReminderCount @@ -9,10 +10,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -28,6 +32,7 @@ class ReminderRepositoryTest { reminderDao = reminderDao, ioDispatcher = Dispatchers.Unconfined ) + runBlocking { whenever(reminderDao.deleteById(any())).thenReturn(1) } } @Test @@ -150,6 +155,65 @@ class ReminderRepositoryTest { } } + @Test + fun createReminder_rejectsBlankMessage() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + try { + repository.createReminder(location, " ", 1) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("message")) + } + } + } + + @Test + fun createReminder_rejectsUnsupportedTransitionType() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + try { + repository.createReminder(location, "Buy milk", Geofence.GEOFENCE_TRANSITION_DWELL) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("transition")) + } + } + } + + @Test + fun updateReminder_rejectsUnsupportedTransitionType() { + runBlocking { + val reminder = ReminderEntity("rem-1", "loc-1", "Msg", 42, true, 0L) + try { + repository.updateReminder(reminder) + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("transition")) + } + } + } + + @Test + fun cancelReminder_reportsWhetherARowWasDeleted() { + runBlocking { + whenever(reminderDao.deleteById("rem-1")).thenReturn(1) + assertTrue(repository.cancelReminder("rem-1")) + + whenever(reminderDao.deleteById("missing")).thenReturn(0) + assertFalse(repository.cancelReminder("missing")) + } + } + + @Test + fun createReminder_trimsTheMessage() { + runBlocking { + val location = LocationEntity("loc-abc", "Supermarket", 40.0, -3.0) + val reminder = repository.createReminder(location, " Buy milk ", 1) + assertEquals("Buy milk", reminder.message) + } + } + @Test fun observeActiveReminderCounts_delegatesToDao() { runBlocking { diff --git a/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt b/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt index a1d47de..e35702b 100644 --- a/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt +++ b/app/src/test/java/dev/arrase/geotify/data/entity/EntityTest.kt @@ -146,7 +146,8 @@ class EntityTest { assertFalse(reminder.isArrival) assertFalse(reminder.isDeparture) - assertEquals("departure", reminder.triggerTypeString) + // Unsupported transitions must not be reported as a departure. + assertEquals("unknown", reminder.triggerTypeString) } @Test diff --git a/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt b/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt index 8dbc09e..7fbad93 100644 --- a/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt +++ b/app/src/test/java/dev/arrase/geotify/domain/SpatialSearchUseCaseTest.kt @@ -1,12 +1,15 @@ package dev.arrase.geotify.domain import android.location.Location -import dev.arrase.geotify.data.dao.LocationDao +import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.entity.LocationEntity -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.mockito.ArgumentCaptor @@ -18,32 +21,28 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.math.cos +@OptIn(ExperimentalCoroutinesApi::class) class SpatialSearchUseCaseTest { - private val locationDao: LocationDao = mock() + private val locationRepository: LocationRepository = mock() private lateinit var useCase: SpatialSearchUseCase private lateinit var mockedLocation: MockedStatic private var distanceCalculator: (Double, Double, Double, Double) -> Float = { _, _, _, _ -> 0f } @Before fun setUp() { - useCase = SpatialSearchUseCase(locationDao) + useCase = SpatialSearchUseCase(locationRepository, UnconfinedTestDispatcher()) mockedLocation = mockStatic(Location::class.java) mockedLocation.`when` { - Location.distanceBetween( - any(), - any(), - any(), - any(), - any() - ) + Location.distanceBetween(any(), any(), any(), any(), any()) }.thenAnswer { invocation -> - val startLat = invocation.getArgument(0) - val startLon = invocation.getArgument(1) - val destLat = invocation.getArgument(2) - val destLon = invocation.getArgument(3) val buffer = invocation.getArgument(4) - buffer[0] = distanceCalculator(startLat, startLon, destLat, destLon) + buffer[0] = distanceCalculator( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2), + invocation.getArgument(3) + ) null } } @@ -54,132 +53,143 @@ class SpatialSearchUseCaseTest { } @Test - fun execute_computesCorrectBoundingBox() = runBlocking { + fun `computes the expected bounding box`() = runTest { val centerLat = 40.0 val centerLon = -3.0 val radiusKm = 10f - val radiusMeters = 10000.0 - val latDegreesChange = radiusMeters / 111320.0 - val cosLat = cos(Math.toRadians(centerLat)) - val lonDegreesChange = radiusMeters / (111320.0 * cosLat) + val latDelta = radiusKm * 1000.0 / 111_320.0 + val lonDelta = radiusKm * 1000.0 / (111_320.0 * cos(Math.toRadians(centerLat))) - val expectedMinLat = centerLat - latDegreesChange - val expectedMaxLat = centerLat + latDegreesChange - val expectedMinLon = centerLon - lonDegreesChange - val expectedMaxLon = centerLon + lonDegreesChange + givenNoCandidates() - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(emptyList()) + useCase(centerLat, centerLon, radiusKm) - useCase.execute(centerLat, centerLon, radiusKm) + val box = captureBoundingBox() + assertEquals(centerLat - latDelta, box.minLat, 0.0001) + assertEquals(centerLat + latDelta, box.maxLat, 0.0001) + assertEquals(centerLon - lonDelta, box.minLon, 0.0001) + assertEquals(centerLon + lonDelta, box.maxLon, 0.0001) + } - val minLatCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLatCaptor = ArgumentCaptor.forClass(Double::class.java) - val minLonCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLonCaptor = ArgumentCaptor.forClass(Double::class.java) + @Test + fun `spans all longitudes at the poles`() = runTest { + givenNoCandidates() - verify(locationDao).getLocationsInBoundingBox( - minLatCaptor.capture(), - maxLatCaptor.capture(), - minLonCaptor.capture(), - maxLonCaptor.capture() - ) + useCase(centerLat = 90.0, centerLon = 0.0, radiusKm = 5f) - assertEquals(expectedMinLat, minLatCaptor.value, 0.0001) - assertEquals(expectedMaxLat, maxLatCaptor.value, 0.0001) - assertEquals(expectedMinLon, minLonCaptor.value, 0.0001) - assertEquals(expectedMaxLon, maxLonCaptor.value, 0.0001) + val box = captureBoundingBox() + assertEquals(-360.0, box.minLon, 0.0001) + assertEquals(360.0, box.maxLon, 0.0001) } @Test - fun execute_whenCosLatNonPositive_uses360DegreesChange() = runBlocking { - val centerLat = 100.0 - val centerLon = 10.0 - val radiusKm = 5f - - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(emptyList()) + fun `produces a window that crosses the antimeridian near longitude 180`() = runTest { + givenNoCandidates() - useCase.execute(centerLat, centerLon, radiusKm) + // 500 km at the equator is ~4.5 degrees of longitude, enough to pass 180 from 179. + useCase(centerLat = 0.0, centerLon = 179.0, radiusKm = 500f) - val minLonCaptor = ArgumentCaptor.forClass(Double::class.java) - val maxLonCaptor = ArgumentCaptor.forClass(Double::class.java) - - verify(locationDao).getLocationsInBoundingBox( - any(), - any(), - minLonCaptor.capture(), - maxLonCaptor.capture() - ) + val box = captureBoundingBox() + assertTrue("expected maxLon > 180 but was ${box.maxLon}", box.maxLon > 180.0) + assertTrue(box.minLon > 0.0) + } - assertEquals(centerLon - 360.0, minLonCaptor.value, 0.0001) - assertEquals(centerLon + 360.0, maxLonCaptor.value, 0.0001) + @Test + fun `rejects an out-of-range latitude`() = runTest { + assertRejects { useCase(centerLat = 91.0, centerLon = 0.0, radiusKm = 1f) } } @Test - fun execute_filtersCandidatesOutsideRadius_andSortsByDistanceAscending() = runBlocking { - val candidateClose = LocationEntity("1", "Close", 40.01, -3.01) - val candidateMedium = LocationEntity("2", "Medium", 40.02, -3.02) - val candidateFar = LocationEntity("3", "Far", 40.10, -3.10) + fun `rejects an out-of-range longitude`() = runTest { + assertRejects { useCase(centerLat = 0.0, centerLon = 181.0, radiusKm = 1f) } + } - val candidates = listOf(candidateMedium, candidateFar, candidateClose) - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) + @Test + fun `rejects a negative radius`() = runTest { + assertRejects { useCase(centerLat = 0.0, centerLon = 0.0, radiusKm = -1f) } + } + @Test + fun `filters candidates beyond the radius and sorts by distance`() = runTest { + val close = location("1", 40.01, -3.01) + val medium = location("2", 40.02, -3.02) + val far = location("3", 40.10, -3.10) + givenCandidates(medium, far, close) distanceCalculator = { _, _, destLat, _ -> when (destLat) { - 40.01 -> 1000f - 40.02 -> 3000f - 40.10 -> 8000f - else -> 0f + 40.01 -> 1_000f + 40.02 -> 3_000f + else -> 8_000f } } - val result = useCase.execute(40.0, -3.0, 5.0f) + val result = useCase(40.0, -3.0, 5.0f) - assertEquals(2, result.size) - assertEquals(candidateClose, result[0]) - assertEquals(candidateMedium, result[1]) + assertEquals(listOf(close, medium), result) } @Test - fun execute_limitsResultsToMaxPoiGeofences() = runBlocking { - val candidates = (0 until 120).map { i -> - LocationEntity( - id = "id-$i", - alias = "Alias $i", - latitude = 40.0 + (i * 0.001), - longitude = -3.0 - ) + fun `limits results to the GMS geofence budget`() = runTest { + val candidates = (0 until 120).map { index -> + location("id-$index", 40.0 + index * 0.001, -3.0) } - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) - + givenCandidates(*candidates.toTypedArray()) distanceCalculator = { _, _, destLat, _ -> - val index = Math.round((destLat - 40.0) / 0.001).toInt() - index * 10f + (Math.round((destLat - 40.0) / 0.001) * 10).toFloat() } - val result = useCase.execute(40.0, -3.0, 10.0f) + val result = useCase(40.0, -3.0, 10.0f) assertEquals(SpatialSearchUseCase.MAX_POI_GEOFENCES, result.size) - assertEquals(99, result.size) assertEquals("id-0", result.first().id) assertEquals("id-98", result.last().id) } @Test - fun execute_whenAllCandidatesOutsideRadius_returnsEmptyList() = runBlocking { - val candidates = listOf( - LocationEntity("1", "Too Far", 40.5, -3.5) - ) - whenever(locationDao.getLocationsInBoundingBox(any(), any(), any(), any())) - .thenReturn(candidates) + fun `returns nothing when every candidate is out of range`() = runTest { + givenCandidates(location("1", 40.5, -3.5)) + distanceCalculator = { _, _, _, _ -> 20_000f } - distanceCalculator = { _, _, _, _ -> 20000f } + assertTrue(useCase(40.0, -3.0, 5.0f).isEmpty()) + } + + private suspend fun assertRejects(block: suspend () -> Unit) { + try { + block() + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message.orEmpty().isNotBlank()) + } + } - val result = useCase.execute(40.0, -3.0, 5.0f) + private fun location(id: String, latitude: Double, longitude: Double) = + LocationEntity(id = id, alias = "Alias $id", latitude = latitude, longitude = longitude) - assertTrue(result.isEmpty()) + private suspend fun givenNoCandidates() { + whenever(locationRepository.findLocationsInBoundingBox(any(), any(), any(), any())) + .thenReturn(emptyList()) + } + + private suspend fun givenCandidates(vararg candidates: LocationEntity) { + whenever(locationRepository.findLocationsInBoundingBox(any(), any(), any(), any())) + .thenReturn(candidates.toList()) } + + private suspend fun captureBoundingBox(): BoundingBox { + val minLat = ArgumentCaptor.forClass(Double::class.java) + val maxLat = ArgumentCaptor.forClass(Double::class.java) + val minLon = ArgumentCaptor.forClass(Double::class.java) + val maxLon = ArgumentCaptor.forClass(Double::class.java) + verify(locationRepository).findLocationsInBoundingBox( + minLat.capture(), maxLat.capture(), minLon.capture(), maxLon.capture() + ) + return BoundingBox(minLat.value, maxLat.value, minLon.value, maxLon.value) + } + + private data class BoundingBox( + val minLat: Double, + val maxLat: Double, + val minLon: Double, + val maxLon: Double + ) } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt index 0305d55..0bcb058 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/LocationsViewModelTest.kt @@ -1,6 +1,7 @@ package dev.arrase.geotify.ui.screen import android.location.Location +import dev.arrase.geotify.R import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults @@ -14,6 +15,7 @@ import dev.arrase.geotify.ui.UiText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -41,23 +43,15 @@ class LocationsViewModelTest { private val settingsManager: SettingsManager = mock() private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var viewModel: LocationsViewModel + private val messages = mutableListOf() @Before fun setUp() { Dispatchers.setMain(testDispatcher) - + runBlocking { whenever(locationRepository.deleteLocation(any())).thenReturn(true) } whenever(locationRepository.observeLocations()).thenReturn(flowOf(emptyList())) whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(emptyList())) whenever(settingsManager.mapTheme).thenReturn(flowOf(SettingsDefaults.MAP_THEME)) - - viewModel = LocationsViewModel( - locationRepository = locationRepository, - reminderRepository = reminderRepository, - geofenceOrchestrator = geofenceOrchestrator, - locationProvider = locationProvider, - settingsManager = settingsManager - ) } @After @@ -65,224 +59,177 @@ class LocationsViewModelTest { Dispatchers.resetMain() } - // ── StateFlows initialization tests ── + private fun buildViewModel(): LocationsViewModel = LocationsViewModel( + locationRepository = locationRepository, + reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, + locationProvider = locationProvider, + settingsManager = settingsManager + ) + + private fun LocationsViewModel.collectingMessages(scope: kotlinx.coroutines.CoroutineScope) = + scope.launch(testDispatcher) { messagesFlow.collect { messages.add(it) } } + + // ── State exposure ── @Test - fun locations_emitsFlowFromRepository() { - runTest { - val entities = listOf(LocationEntity("1", "Casa", 40.0, -3.0)) - whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) - - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.locations.collect {} } - - assertEquals(entities, vm.locations.value) - } + fun `locations emits the repository flow`() = runTest { + val entities = listOf(LocationEntity("1", "Casa", 40.0, -3.0)) + whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.locations.collect {} } + + assertEquals(entities, vm.locations.value) } @Test - fun activeReminderCounts_mapsAndEmitsCountsFromRepository() { - runTest { - val counts = listOf( - LocationReminderCount("loc-1", 3), - LocationReminderCount("loc-2", 1) - ) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) - - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } - - val expected = mapOf("loc-1" to 3, "loc-2" to 1) - assertEquals(expected, vm.activeReminderCounts.value) - } + fun `activeReminderCounts maps counts keyed by location id`() = runTest { + val counts = listOf(LocationReminderCount("loc-1", 3), LocationReminderCount("loc-2", 1)) + whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } + + assertEquals(mapOf("loc-1" to 3, "loc-2" to 1), vm.activeReminderCounts.value) } @Test - fun mapTheme_emitsThemeFromSettings() { - runTest { - whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.DARK)) + fun `mapTheme emits the configured theme`() = runTest { + whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.DARK)) - val vm = LocationsViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } - assertEquals(ThemeSetting.DARK, vm.mapTheme.value) - } + assertEquals(ThemeSetting.DARK, vm.mapTheme.value) } - // ── saveLocation tests ── + // ── saveLocation ── @Test - fun saveLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `saveLocation saves and recalculates`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 1000) + vm.saveLocation("Casa", 40.0, -3.0, 150f, 1000) - verify(locationRepository).saveLocation("Casa", 40.0, -3.0, 150f, 1000) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(locationRepository).saveLocation("Casa", 40.0, -3.0, 150f, 1000) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun saveLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) - .thenThrow(RuntimeException("Duplicate alias")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 0) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Duplicate alias"), messages[0]) - } + fun `saveLocation surfaces the failure and skips recalculation`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException("Duplicate alias")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not save the location: Duplicate alias")), + messages + ) } @Test - fun saveLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.saveLocation("Casa", 40.0, -3.0, 150f, 0) - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error saving location"), messages[0]) - } - } + fun `saveLocation falls back to a generic message when the cause has none`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException()) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - // ── updateLocation tests ── + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) - @Test - fun updateLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - verify(locationRepository).updateLocation(location) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + assertEquals(listOf(UiText.DynamicString("Could not save the location")), messages) } @Test - fun updateLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - whenever(locationRepository.updateLocation(location)) - .thenThrow(RuntimeException("Location not found")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Location not found"), messages[0]) - } - } + fun `a message emitted before collection starts is not lost`() = runTest { + whenever(locationRepository.saveLocation(any(), any(), any(), any(), any())) + .thenThrow(RuntimeException("boom")) + val vm = buildViewModel() - @Test - fun updateLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - val location = LocationEntity("1", "Trabajo", 41.0, 2.0) - whenever(locationRepository.updateLocation(location)) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateLocation(location) - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error updating location"), messages[0]) - } + // No collector yet: a SharedFlow would drop this event silently. + vm.saveLocation("Casa", 40.0, -3.0, 150f, 0) + vm.collectingMessages(backgroundScope) + + assertEquals(listOf(UiText.DynamicString("Could not save the location: boom")), messages) } - // ── deleteLocation tests ── + // ── updateLocation ── @Test - fun deleteLocation_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `updateLocation saves and recalculates`() = runTest { + val location = LocationEntity("1", "Trabajo", 41.0, 2.0) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.deleteLocation("Casa") + vm.updateLocation(location) - verify(locationRepository).deleteLocation("Casa") - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(locationRepository).updateLocation(location) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun deleteLocation_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(locationRepository.deleteLocation("Casa")) - .thenThrow(RuntimeException("Delete constraint error")) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.deleteLocation("Casa") - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Delete constraint error"), messages[0]) - } + fun `updateLocation surfaces the failure and skips recalculation`() = runTest { + val location = LocationEntity("1", "Trabajo", 41.0, 2.0) + whenever(locationRepository.updateLocation(location)) + .thenThrow(RuntimeException("Location not found")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.updateLocation(location) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not update the location: Location not found")), + messages + ) } + // ── deleteLocation ── + @Test - fun deleteLocation_onExceptionWithoutMessage_emitsDefaultErrorMessage() { - runTest { - whenever(locationRepository.deleteLocation("Casa")) - .thenThrow(RuntimeException()) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.deleteLocation("Casa") - - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Unknown error deleting location"), messages[0]) - } - } + fun `deleteLocation removes by alias, recalculates and confirms`() = runTest { + val location = LocationEntity("1", "Casa", 40.0, -3.0) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - // ── getCurrentLocation tests ── + vm.deleteLocation(location) + + verify(locationRepository).deleteLocation("Casa") + verify(geofenceOrchestrator).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.location_deleted, "Casa")), messages) + } @Test - fun getCurrentLocation_returnsLocationFromProvider() { - runTest { - val mockLocation: Location = mock() - whenever(locationProvider.getCurrentLocation()).thenReturn(mockLocation) + fun `deleteLocation surfaces the failure and skips recalculation`() = runTest { + whenever(locationRepository.deleteLocation("Casa")) + .thenThrow(RuntimeException("Delete constraint error")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val result = viewModel.getCurrentLocation() + vm.deleteLocation(LocationEntity("1", "Casa", 40.0, -3.0)) - assertEquals(mockLocation, result) - verify(locationProvider).getCurrentLocation() - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not delete the location: Delete constraint error")), + messages + ) } + // ── getCurrentLocation ── + @Test - fun getCurrentLocation_whenProviderReturnsNull_returnsNull() { - runTest { - whenever(locationProvider.getCurrentLocation()).thenReturn(null) + fun `getCurrentLocation delegates to the provider`() = runTest { + val fix: Location = mock() + whenever(locationProvider.getCurrentLocation()).thenReturn(fix) - val result = viewModel.getCurrentLocation() + assertEquals(fix, buildViewModel().getCurrentLocation()) + verify(locationProvider).getCurrentLocation() + } + + @Test + fun `getCurrentLocation returns null when no fix is available`() = runTest { + whenever(locationProvider.getCurrentLocation()).thenReturn(null) - assertNull(result) - verify(locationProvider).getCurrentLocation() - } + assertNull(buildViewModel().getCurrentLocation()) } } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt index da87a0c..627109a 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/RemindersViewModelTest.kt @@ -2,20 +2,23 @@ package dev.arrase.geotify.ui.screen import android.location.Location import com.google.android.gms.location.Geofence +import dev.arrase.geotify.R +import dev.arrase.geotify.data.LatLng import dev.arrase.geotify.data.LocationRepository import dev.arrase.geotify.data.ReminderRepository import dev.arrase.geotify.data.SettingsDefaults import dev.arrase.geotify.data.SettingsManager import dev.arrase.geotify.data.ThemeSetting import dev.arrase.geotify.data.entity.LocationEntity -import dev.arrase.geotify.data.entity.LocationReminderCount import dev.arrase.geotify.data.entity.ReminderEntity import dev.arrase.geotify.geofence.GeofenceOrchestrator import dev.arrase.geotify.location.LocationProvider import dev.arrase.geotify.ui.UiText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -43,28 +46,18 @@ class RemindersViewModelTest { private val settingsManager: SettingsManager = mock() private val testDispatcher = UnconfinedTestDispatcher() - private lateinit var viewModel: RemindersViewModel + private val messages = mutableListOf() @Before fun setUp() { Dispatchers.setMain(testDispatcher) - + runBlocking { whenever(reminderRepository.cancelReminder(any())).thenReturn(true) } whenever(locationRepository.observeLocations()).thenReturn(flowOf(emptyList())) whenever(reminderRepository.observeReminders()).thenReturn(flowOf(emptyList())) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(emptyList())) whenever(settingsManager.mapTheme).thenReturn(flowOf(SettingsDefaults.MAP_THEME)) - whenever(settingsManager.lastRecalcLat).thenReturn(flowOf(null)) - whenever(settingsManager.lastRecalcLng).thenReturn(flowOf(null)) + whenever(settingsManager.lastRecalcLocation).thenReturn(flowOf(null)) whenever(settingsManager.innerRadiusR).thenReturn(flowOf(SettingsDefaults.INNER_RADIUS_R)) whenever(settingsManager.outerRadiusN).thenReturn(flowOf(SettingsDefaults.OUTER_RADIUS_N)) - - viewModel = RemindersViewModel( - locationRepository = locationRepository, - reminderRepository = reminderRepository, - geofenceOrchestrator = geofenceOrchestrator, - locationProvider = locationProvider, - settingsManager = settingsManager - ) } @After @@ -72,231 +65,228 @@ class RemindersViewModelTest { Dispatchers.resetMain() } - // ── StateFlows initialization tests ── + private fun buildViewModel(): RemindersViewModel = RemindersViewModel( + locationRepository = locationRepository, + reminderRepository = reminderRepository, + geofenceOrchestrator = geofenceOrchestrator, + locationProvider = locationProvider, + settingsManager = settingsManager + ) + + private fun RemindersViewModel.collectingMessages(scope: CoroutineScope) = + scope.launch(testDispatcher) { messagesFlow.collect { messages.add(it) } } + + // ── State exposure ── @Test - fun locations_emitsFlowFromRepository() { - runTest { - val entities = listOf(LocationEntity("loc-1", "Casa", 40.0, -3.0)) - whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.locations.collect {} } - - assertEquals(entities, vm.locations.value) - } + fun `locations emits the repository flow`() = runTest { + val entities = listOf(LocationEntity("loc-1", "Casa", 40.0, -3.0)) + whenever(locationRepository.observeLocations()).thenReturn(flowOf(entities)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.locations.collect {} } + + assertEquals(entities, vm.locations.value) } @Test - fun reminders_emitsFlowFromRepository() { - runTest { - val remindersList = listOf( - ReminderEntity("rem-1", "loc-1", "Comprar leche", Geofence.GEOFENCE_TRANSITION_ENTER, createdAt = 100L) - ) - whenever(reminderRepository.observeReminders()).thenReturn(flowOf(remindersList)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.reminders.collect {} } - - assertEquals(remindersList, vm.reminders.value) - } + fun `reminders emits the repository flow`() = runTest { + val reminders = listOf( + ReminderEntity("rem-1", "loc-1", "Comprar leche", Geofence.GEOFENCE_TRANSITION_ENTER, createdAt = 100L) + ) + whenever(reminderRepository.observeReminders()).thenReturn(flowOf(reminders)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.reminders.collect {} } + + assertEquals(reminders, vm.reminders.value) } @Test - fun activeReminderCounts_mapsAndEmitsCountsFromRepository() { - runTest { - val counts = listOf( - LocationReminderCount("loc-1", 2), - LocationReminderCount("loc-2", 5) - ) - whenever(reminderRepository.observeActiveReminderCounts()).thenReturn(flowOf(counts)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.activeReminderCounts.collect {} } - - val expected = mapOf("loc-1" to 2, "loc-2" to 5) - assertEquals(expected, vm.activeReminderCounts.value) - } + fun `settings flows expose their configured values`() = runTest { + whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.LIGHT)) + whenever(settingsManager.lastRecalcLocation).thenReturn(flowOf(LatLng(40.4168, -3.7038))) + whenever(settingsManager.innerRadiusR).thenReturn(flowOf(10f)) + whenever(settingsManager.outerRadiusN).thenReturn(flowOf(25f)) + + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } + backgroundScope.launch(testDispatcher) { vm.lastRecalcLocation.collect {} } + backgroundScope.launch(testDispatcher) { vm.innerRadiusR.collect {} } + backgroundScope.launch(testDispatcher) { vm.outerRadiusN.collect {} } + + assertEquals(ThemeSetting.LIGHT, vm.mapTheme.value) + assertEquals(LatLng(40.4168, -3.7038), vm.lastRecalcLocation.value) + assertEquals(10f, vm.innerRadiusR.value) + assertEquals(25f, vm.outerRadiusN.value) } @Test - fun settingsFlows_emitConfiguredSettings() { - runTest { - whenever(settingsManager.mapTheme).thenReturn(flowOf(ThemeSetting.LIGHT)) - whenever(settingsManager.lastRecalcLat).thenReturn(flowOf(40.4168)) - whenever(settingsManager.lastRecalcLng).thenReturn(flowOf(-3.7038)) - whenever(settingsManager.innerRadiusR).thenReturn(flowOf(10f)) - whenever(settingsManager.outerRadiusN).thenReturn(flowOf(25f)) - - val vm = RemindersViewModel( - locationRepository, reminderRepository, geofenceOrchestrator, locationProvider, settingsManager - ) - backgroundScope.launch(testDispatcher) { vm.mapTheme.collect {} } - backgroundScope.launch(testDispatcher) { vm.lastRecalcLat.collect {} } - backgroundScope.launch(testDispatcher) { vm.lastRecalcLng.collect {} } - backgroundScope.launch(testDispatcher) { vm.innerRadiusR.collect {} } - backgroundScope.launch(testDispatcher) { vm.outerRadiusN.collect {} } - - assertEquals(ThemeSetting.LIGHT, vm.mapTheme.value) - assertEquals(40.4168, vm.lastRecalcLat.value) - assertEquals(-3.7038, vm.lastRecalcLng.value) - assertEquals(10f, vm.innerRadiusR.value) - assertEquals(25f, vm.outerRadiusN.value) - } + fun `lastRecalcLocation reports null before the first recalculation`() = runTest { + val vm = buildViewModel() + backgroundScope.launch(testDispatcher) { vm.lastRecalcLocation.collect {} } + + assertNull(vm.lastRecalcLocation.value) } - // ── createReminder tests ── + // ── createReminder ── @Test - fun createReminder_whenLocationExists_createsReminderAndTriggersRecalculation() { - runTest { - val location = LocationEntity("loc-1", "Super", 40.0, -3.0) - whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + fun `createReminder creates and recalculates when the location exists`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - viewModel.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - - verify(reminderRepository).createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(reminderRepository).createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun createReminder_whenLocationDoesNotExist_returnsEarlyWithoutCreatingOrRecalculating() { - runTest { - whenever(locationRepository.findLocationById("non-existent")).thenReturn(null) + fun `createReminder reports a missing location instead of failing silently`() = runTest { + whenever(locationRepository.findLocationById("gone")).thenReturn(null) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.createReminder("gone", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - viewModel.createReminder("non-existent", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + verify(reminderRepository, never()).createReminder(any(), any(), any()) + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.err_location_missing)), messages) + } - verify(reminderRepository, never()).createReminder(any(), any(), any()) - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + @Test + fun `createReminder surfaces the failure and skips recalculation`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) + .thenThrow(RuntimeException("Reminder insert failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not create the reminder: Reminder insert failed")), + messages + ) } @Test - fun createReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val location = LocationEntity("loc-1", "Super", 40.0, -3.0) - whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) - whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) - .thenThrow(RuntimeException("Reminder insert failed")) - - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Reminder insert failed"), messages[0]) - } + fun `createReminder falls back to a generic message when the cause has none`() = runTest { + val location = LocationEntity("loc-1", "Super", 40.0, -3.0) + whenever(locationRepository.findLocationById("loc-1")).thenReturn(location) + whenever(reminderRepository.createReminder(location, "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER)) + .thenThrow(RuntimeException()) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.createReminder("loc-1", "Comprar café", Geofence.GEOFENCE_TRANSITION_ENTER) + + assertEquals(listOf(UiText.DynamicString("Could not create the reminder")), messages) } - // ── updateReminder tests ── + // ── updateReminder ── @Test - fun updateReminder_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } - - viewModel.updateReminder(reminder) - - verify(reminderRepository).updateReminder(reminder) - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + fun `updateReminder saves and recalculates`() = runTest { + val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.updateReminder(reminder) + + verify(reminderRepository).updateReminder(reminder) + verify(geofenceOrchestrator).triggerRecalculation() + assertTrue(messages.isEmpty()) } @Test - fun updateReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) - whenever(reminderRepository.updateReminder(reminder)) - .thenThrow(RuntimeException("Update failed")) + fun `updateReminder surfaces the failure and skips recalculation`() = runTest { + val reminder = ReminderEntity("rem-1", "loc-1", "Nuevo texto", Geofence.GEOFENCE_TRANSITION_EXIT, createdAt = 100L) + whenever(reminderRepository.updateReminder(reminder)).thenThrow(RuntimeException("Update failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.updateReminder(reminder) - viewModel.updateReminder(reminder) - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Update failed"), messages[0]) - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not update the reminder: Update failed")), + messages + ) } - // ── cancelReminder tests ── + // ── cancelReminder ── @Test - fun cancelReminder_success_callsRepositoryAndTriggersRecalculation() { - runTest { - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + fun `cancelReminder cancels, recalculates and confirms`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - viewModel.cancelReminder("rem-1") + vm.cancelReminder("rem-1") - verify(reminderRepository).cancelReminder("rem-1") - verify(geofenceOrchestrator).triggerRecalculation() - assertTrue(messages.isEmpty()) - } + verify(reminderRepository).cancelReminder("rem-1") + verify(geofenceOrchestrator).triggerRecalculation() + assertEquals(listOf(UiText.StringResource(R.string.reminder_cancelled)), messages) } @Test - fun cancelReminder_onRepositoryException_emitsErrorMessageToSnackbar() { - runTest { - whenever(reminderRepository.cancelReminder("rem-1")) - .thenThrow(RuntimeException("Cancel failed")) + fun `cancelReminder surfaces the failure and skips recalculation`() = runTest { + whenever(reminderRepository.cancelReminder("rem-1")).thenThrow(RuntimeException("Cancel failed")) + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } - val messages = mutableListOf() - backgroundScope.launch(testDispatcher) { viewModel.snackbarMessage.collect { messages.add(it) } } + vm.cancelReminder("rem-1") - viewModel.cancelReminder("rem-1") - - verify(geofenceOrchestrator, never()).triggerRecalculation() - assertEquals(1, messages.size) - assertEquals(UiText.DynamicString("Error: Cancel failed"), messages[0]) - } + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not cancel the reminder: Cancel failed")), + messages + ) } - // ── getCurrentLocation tests ── - @Test - fun getCurrentLocation_returnsLocationFromProvider() { - runTest { - val mockLocation: Location = mock() - whenever(locationProvider.getCurrentLocation()).thenReturn(mockLocation) + fun `cancelReminder reports a failure when the reminder is already gone`() = runTest { + runBlocking { whenever(reminderRepository.cancelReminder("gone")).thenReturn(false) } + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.cancelReminder("gone") - val result = viewModel.getCurrentLocation() + verify(geofenceOrchestrator, never()).triggerRecalculation() + assertEquals( + listOf(UiText.DynamicString("Could not cancel the reminder: Reminder gone no longer exists")), + messages + ) + } - assertEquals(mockLocation, result) - verify(locationProvider).getCurrentLocation() - } + @Test + fun `cancelling an active reminder and deleting a completed one are worded differently`() = runTest { + val vm = buildViewModel().also { it.collectingMessages(backgroundScope) } + + vm.cancelReminder("rem-1", cancelled = true) + vm.cancelReminder("rem-2", cancelled = false) + + assertEquals( + listOf( + UiText.StringResource(R.string.reminder_cancelled), + UiText.StringResource(R.string.reminder_deleted) + ), + messages + ) } + // ── getCurrentLocation ── + @Test - fun getCurrentLocation_whenProviderReturnsNull_returnsNull() { - runTest { - whenever(locationProvider.getCurrentLocation()).thenReturn(null) + fun `getCurrentLocation delegates to the provider`() = runTest { + val fix: Location = mock() + whenever(locationProvider.getCurrentLocation()).thenReturn(fix) + + assertEquals(fix, buildViewModel().getCurrentLocation()) + verify(locationProvider).getCurrentLocation() + } - val result = viewModel.getCurrentLocation() + @Test + fun `getCurrentLocation returns null when no fix is available`() = runTest { + whenever(locationProvider.getCurrentLocation()).thenReturn(null) - assertNull(result) - verify(locationProvider).getCurrentLocation() - } + assertNull(buildViewModel().getCurrentLocation()) } } diff --git a/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt b/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt index 867e8b9..ddb5d85 100644 --- a/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt +++ b/app/src/test/java/dev/arrase/geotify/ui/screen/SettingsViewModelTest.kt @@ -17,8 +17,10 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -170,21 +172,24 @@ class SettingsViewModelTest { } @Test - fun setOuterRadiusN_callsSettingsManagerAndTriggersRecalculation() = runTest(testDispatcher) { - viewModel.setOuterRadiusN(3000f) + fun setRadii_persistsBothRadiiAndTriggersASingleRecalculation() = runTest(testDispatcher) { + viewModel.setRadii(outerRadiusKm = 3.0f, innerRadiusKm = 1.0f) advanceUntilIdle() - verify(settingsManager).setOuterRadiusN(3000f) - verify(geofenceOrchestrator).triggerRecalculation() + verify(settingsManager).setOuterRadiusN(3.0f) + verify(settingsManager).setInnerRadiusR(1.0f) + // One recalculation for the pair: the sliding window depends on both. + verify(geofenceOrchestrator, times(1)).triggerRecalculation() } @Test - fun setInnerRadiusR_callsSettingsManagerAndTriggersRecalculation() = runTest(testDispatcher) { - viewModel.setInnerRadiusR(1000f) + fun setRadii_skipsRecalculationWhenPersistingFails() = runTest(testDispatcher) { + whenever(settingsManager.setOuterRadiusN(any())).thenThrow(RuntimeException("disk full")) + + viewModel.setRadii(outerRadiusKm = 3.0f, innerRadiusKm = 1.0f) advanceUntilIdle() - verify(settingsManager).setInnerRadiusR(1000f) - verify(geofenceOrchestrator).triggerRecalculation() + verify(geofenceOrchestrator, never()).triggerRecalculation() } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8712ffd..700bcab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,6 @@ coreKtx = "1.19.1" activityCompose = "1.13.0" lifecycleRuntimeKtx = "2.11.0" lifecycleViewmodelCompose = "2.11.0" -navigationCompose = "2.10.0" room = "2.8.5" playServicesLocation = "21.4.0" appfunctions = "1.0.0-alpha09" @@ -27,7 +26,6 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } @@ -40,6 +38,7 @@ androidx-compose-material-icons-extended = { group = "androidx.compose.material" androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-appfunctions = { group = "androidx.appfunctions", name = "appfunctions", version.ref = "appfunctions" } androidx-appfunctions-service = { group = "androidx.appfunctions", name = "appfunctions-service", version.ref = "appfunctions" } androidx-appfunctions-compiler = { group = "androidx.appfunctions", name = "appfunctions-compiler", version.ref = "appfunctions" } @@ -52,7 +51,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version = "6.1.20" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.4.0" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version = "1.3.0" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }