commit dea0ee498b332db47b4651befee8f523e326b19b Author: ale Date: Sun Jul 13 18:22:17 2025 +0200 initial commit Signed-off-by: ale diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23732d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +.kotlin diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b06619 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# Location Simulator Android App 📍 + +Una aplicación Android desarrollada en Kotlin que simula la funcionalidad de ubicaciones de Google **sin necesidad de permisos reales de ubicación**. Perfecta para desarrollo, testing y demostraciones. + +## 🎯 Características + +- ✅ **Sin permisos de ubicación**: Simula ubicaciones sin acceso real al GPS +- 🗺️ **Última ubicación de Google**: Consulta y muestra la última posición guardada por Google (simulada) +- 🎲 **Ubicaciones aleatorias**: Genera posiciones aleatorias cerca de ciudades populares +- 🌍 **Ubicaciones populares**: Lista predefinida de ciudades importantes +- 📍 **Ubicación personalizada**: Permite ingresar coordenadas específicas +- 💾 **Persistencia**: Guarda la última ubicación simulada + +## 🏗️ Tecnologías + +- **Kotlin** - Lenguaje principal +- **Jetpack Compose** - UI moderna y declarativa +- **Material 3** - Diseño y componentes +- **Architecture Components** - ViewModel, StateFlow +- **SharedPreferences** - Almacenamiento local +- **Coroutines** - Programación asíncrona + +## 📱 Funcionalidades + +### 1. Ubicación Simulada +- **Última de Google**: Obtiene la última ubicación que Google tendría guardada +- **Simular Nueva**: Genera una nueva ubicación actual simulada +- Muestra coordenadas, precisión y timestamp + +### 2. Ubicaciones Populares +Ciudades predefinidas disponibles: +- 🇪🇸 Madrid, España +- 🇫🇷 París, Francia +- 🇬🇧 Londres, Reino Unido +- 🇺🇸 Nueva York, Estados Unidos +- 🇯🇵 Tokio, Japón +- 🇧🇷 São Paulo, Brasil +- 🇦🇺 Sídney, Australia + +Cada ciudad permite: +- Ir directamente a esa ubicación +- Generar una posición aleatoria cercana (radio de 1km) + +### 3. Ubicación Personalizada +- Ingreso manual de latitud y longitud +- Validación de coordenadas +- Guardado automático de la nueva posición + +## 🚀 Instalación y Configuración + +### Prerrequisitos +- Android Studio Arctic Fox o superior +- SDK mínimo: API 24 (Android 7.0) +- SDK objetivo: API 36 + +### Clonar e Instalar + +```bash +# Clonar el repositorio +git clone https://git.manalejandro.com/ale/location.git +cd location + +# Abrir en Android Studio +# File -> Open -> Seleccionar carpeta del proyecto + +# Sincronizar dependencias +# Build -> Make Project +``` + +### Configuración +No se requiere configuración adicional. La app funciona inmediatamente sin permisos especiales. + +## 🎮 Uso + +1. **Consultar última ubicación**: + - Toca "Última de Google" para obtener la posición guardada + - La app simula una consulta a los servicios de Google + +2. **Generar nueva ubicación**: + - Toca "Simular Nueva" para crear una ubicación actual + +3. **Usar ubicaciones populares**: + - Selecciona una ciudad para ir directamente + - Usa "Aleatorio" para generar posiciones cercanas + +4. **Ubicación personalizada**: + - Ingresa latitud y longitud manualmente + - Toca "Simular Ubicación" para aplicar + +## 📂 Estructura del Proyecto + +``` +app/src/main/java/com/manalejandro/location/ +├── MainActivity.kt # Actividad principal y UI +├── LocationViewModel.kt # Lógica de negocio y estado +├── LocationService.kt # Servicios de ubicación simulados +└── ui/theme/ # Tema y estilos Material 3 + ├── Color.kt + ├── Theme.kt + └── Type.kt +``` + +## 🔧 Arquitectura + +La aplicación sigue el patrón **MVVM (Model-View-ViewModel)**: + +- **View (Compose)**: Interfaz de usuario declarativa +- **ViewModel**: Manejo de estado y lógica de presentación +- **Model (LocationService)**: Simulación de servicios de ubicación + +### Flujo de Datos +``` +UI (Compose) ↔ ViewModel ↔ LocationService ↔ SharedPreferences +``` + +## 🎨 Diseño + +- **Material 3 Design**: Componentes modernos y accesibles +- **Responsive**: Adaptable a diferentes tamaños de pantalla +- **Dark/Light Theme**: Soporte automático según configuración del sistema +- **Cards y Sections**: Organización clara de funcionalidades + +## 🧪 Testing + +La aplicación incluye pruebas básicas: + +```bash +# Ejecutar tests unitarios +./gradlew test + +# Ejecutar tests instrumentados +./gradlew connectedAndroidTest +``` + +## 🤝 Contribuir + +1. Fork el proyecto +2. Crea una rama para tu feature (`git checkout -b feature/AmazingFeature`) +3. Commit tus cambios (`git commit -m 'Add some AmazingFeature'`) +4. Push a la rama (`git push origin feature/AmazingFeature`) +5. Abre un Pull Request + +## 📝 Casos de Uso + +### Para Desarrolladores +- Testing de aplicaciones que requieren ubicación +- Desarrollo sin depender de ubicación real +- Demostraciones en entornos controlados + +### Para QA/Testing +- Verificar comportamiento con diferentes ubicaciones +- Probar funcionalidades geográficas +- Testing sin moverse físicamente + +### Para Educación +- Enseñar conceptos de geolocalización +- Demostrar APIs de ubicación +- Workshops y tutoriales + +## 🔒 Privacidad + +Esta aplicación **NO**: +- Accede a tu ubicación real +- Requiere permisos de ubicación +- Envía datos a servidores externos +- Almacena información personal + +Toda la funcionalidad es local y simulada. + +## 📄 Licencia + +Este proyecto está bajo la Licencia MIT. Ver el archivo `LICENSE` para más detalles. + +## 👨‍💻 Autor + +**Manuel Alejandro** +- Git: [@manalejandro](https://git.manalejandro.com/ale) + +## 🙏 Agradecimientos + +- Equipo de Android Developers +- Comunidad de Kotlin +- Material Design Team + +--- + +⭐ Si este proyecto te ha sido útil, ¡no olvides darle una estrella! + +## 📸 Screenshots + +> Agrega capturas de pantalla de tu aplicación aquí + +## 🔄 Changelog + +### v1.0.0 +- Lanzamiento inicial +- Simulación de ubicaciones de Google +- Ubicaciones populares predefinidas +- Ubicaciones personalizadas +- UI con Material 3 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..241efe4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.manalejandro.location" + compileSdk = 36 + + defaultConfig { + applicationId = "com.manalejandro.location" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + + // ViewModel y LiveData + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.compose.runtime:runtime-livedata:1.5.4") + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/manalejandro/location/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/manalejandro/location/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..7ed3cfe --- /dev/null +++ b/app/src/androidTest/java/com/manalejandro/location/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.manalejandro.location + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.manalejandro.location", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..80ca8c9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..83b83f7 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/manalejandro/location/LocationService.kt b/app/src/main/java/com/manalejandro/location/LocationService.kt new file mode 100644 index 0000000..25cc0f5 --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/LocationService.kt @@ -0,0 +1,138 @@ +package com.manalejandro.location + +import android.content.Context +import android.location.Location +import kotlinx.coroutines.delay + +class LocationService(private val context: Context) { + + /** + * Simula obtener la última ubicación conocida que Google tendría guardada + * Devuelve una ubicación predeterminada o la última simulada + */ + suspend fun getLastKnownLocation(): Location? { + // Simular un pequeño delay como si fuera una consulta real a Google + delay(1000) + + // Primero intentar obtener la ubicación simulada guardada + val mockedLocation = getMockedLocation() + if (mockedLocation != null) { + // Actualizar el timestamp para simular que Google la tiene guardada + return mockedLocation.apply { + time = System.currentTimeMillis() - (1000 * 60 * 30) // 30 minutos atrás + } + } + + // Si no hay ubicación simulada, devolver una ubicación predeterminada que Google tendría guardada (Madrid) + return Location("google_provider").apply { + latitude = 40.4168 + longitude = -3.7038 + time = System.currentTimeMillis() - (1000 * 60 * 60 * 2) // 2 horas atrás + accuracy = 15.0f // Menos precisión para simular que es una ubicación guardada + } + } + + /** + * Simula obtener la ubicación actual de Google + */ + suspend fun getCurrentLocation(): Location? { + // Simular un delay más largo como si fuera una consulta GPS real + delay(2000) + + return getLastKnownLocation() + } + + /** + * Guarda una ubicación simulada + */ + suspend fun mockLocation(latitude: Double, longitude: Double): Boolean { + return try { + val preferences = context.getSharedPreferences("mock_location", Context.MODE_PRIVATE) + preferences.edit() + .putString("latitude", latitude.toString()) + .putString("longitude", longitude.toString()) + .putLong("timestamp", System.currentTimeMillis()) + .apply() + + // Simular un pequeño delay + delay(500) + true + } catch (e: Exception) { + false + } + } + + /** + * Obtiene la ubicación simulada guardada + */ + fun getMockedLocation(): Location? { + val preferences = context.getSharedPreferences("mock_location", Context.MODE_PRIVATE) + val latStr = preferences.getString("latitude", null) + val lngStr = preferences.getString("longitude", null) + + return if (latStr != null && lngStr != null) { + Location("mock_provider").apply { + latitude = latStr.toDouble() + longitude = lngStr.toDouble() + time = preferences.getLong("timestamp", System.currentTimeMillis()) + accuracy = 5.0f // Simular buena precisión + } + } else null + } + + /** + * Genera ubicaciones aleatorias cercanas a una ubicación base + */ + fun generateRandomNearbyLocation(baseLatitude: Double, baseLongitude: Double, radiusKm: Double = 1.0): Location { + val random = kotlin.random.Random + + // Generar offset aleatorio dentro del radio especificado + val offsetLat = (random.nextDouble() - 0.5) * 2 * (radiusKm / 111.0) // 1 grado lat ≈ 111 km + val offsetLng = (random.nextDouble() - 0.5) * 2 * (radiusKm / (111.0 * kotlin.math.cos(Math.toRadians(baseLatitude)))) + + return Location("random_provider").apply { + latitude = baseLatitude + offsetLat + longitude = baseLongitude + offsetLng + time = System.currentTimeMillis() + accuracy = random.nextFloat() * 20 + 5 // Precisión entre 5-25 metros + } + } + + /** + * Obtiene ubicaciones predefinidas populares en España + */ + fun getPopularLocations(): List> { + return listOf( + "Madrid" to Location("predefined").apply { + latitude = 40.4168 + longitude = -3.7038 + time = System.currentTimeMillis() + accuracy = 10.0f + }, + "Barcelona" to Location("predefined").apply { + latitude = 41.3851 + longitude = 2.1734 + time = System.currentTimeMillis() + accuracy = 10.0f + }, + "Valencia" to Location("predefined").apply { + latitude = 39.4699 + longitude = -0.3763 + time = System.currentTimeMillis() + accuracy = 10.0f + }, + "Sevilla" to Location("predefined").apply { + latitude = 37.3886 + longitude = -5.9823 + time = System.currentTimeMillis() + accuracy = 10.0f + }, + "Bilbao" to Location("predefined").apply { + latitude = 43.2627 + longitude = -2.9253 + time = System.currentTimeMillis() + accuracy = 10.0f + } + ) + } +} diff --git a/app/src/main/java/com/manalejandro/location/LocationViewModel.kt b/app/src/main/java/com/manalejandro/location/LocationViewModel.kt new file mode 100644 index 0000000..c138e60 --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/LocationViewModel.kt @@ -0,0 +1,176 @@ +package com.manalejandro.location + +import android.location.Location +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class LocationState( + val currentLocation: Location? = null, + val mockedLocation: Location? = null, + val popularLocations: List> = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val hasLocationPermission: Boolean = true // Siempre true ya que no necesitamos permisos +) + +class LocationViewModel(private val locationService: LocationService) : ViewModel() { + + private val _uiState = MutableStateFlow(LocationState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadMockedLocation() + loadPopularLocations() + } + + /** + * Carga la ubicación simulada guardada + */ + private fun loadMockedLocation() { + val mockedLocation = locationService.getMockedLocation() + _uiState.value = _uiState.value.copy(mockedLocation = mockedLocation) + } + + /** + * Carga las ubicaciones populares predefinidas + */ + private fun loadPopularLocations() { + val popularLocations = locationService.getPopularLocations() + _uiState.value = _uiState.value.copy(popularLocations = popularLocations) + } + + /** + * Obtiene la última ubicación conocida (simulada) + */ + fun getLastKnownLocation() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val location = locationService.getLastKnownLocation() + _uiState.value = _uiState.value.copy( + currentLocation = location, + isLoading = false, + error = if (location == null) "No se encontró ubicación guardada" else null + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Error al obtener ubicación: ${e.message}" + ) + } + } + } + + /** + * Obtiene la ubicación actual (simulada) + */ + fun getCurrentLocation() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val location = locationService.getCurrentLocation() + _uiState.value = _uiState.value.copy( + currentLocation = location, + isLoading = false, + error = if (location == null) "No se pudo obtener ubicación actual" else null + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Error al obtener ubicación actual: ${e.message}" + ) + } + } + } + + /** + * Simula una nueva ubicación + */ + fun mockLocation(latitude: Double, longitude: Double) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val success = locationService.mockLocation(latitude, longitude) + if (success) { + val mockedLocation = locationService.getMockedLocation() + _uiState.value = _uiState.value.copy( + mockedLocation = mockedLocation, + isLoading = false, + error = null + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Error al simular ubicación" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Error al simular ubicación: ${e.message}" + ) + } + } + } + + /** + * Establece una ubicación predefinida + */ + fun setPopularLocation(location: Location) { + mockLocation(location.latitude, location.longitude) + } + + /** + * Genera una ubicación aleatoria cerca de una ubicación base + */ + fun generateRandomLocation(baseLocation: Location) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val randomLocation = locationService.generateRandomNearbyLocation( + baseLocation.latitude, + baseLocation.longitude, + 2.0 // Radio de 2 km + ) + + val success = locationService.mockLocation(randomLocation.latitude, randomLocation.longitude) + if (success) { + val mockedLocation = locationService.getMockedLocation() + _uiState.value = _uiState.value.copy( + mockedLocation = mockedLocation, + currentLocation = randomLocation, + isLoading = false, + error = null + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Error al generar ubicación aleatoria: ${e.message}" + ) + } + } + } + + /** + * Actualiza el estado de permisos (ya no necesario) + */ + fun updatePermissionStatus(hasPermission: Boolean) { + // No hacemos nada ya que no necesitamos permisos + } + + /** + * Limpia errores + */ + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } +} diff --git a/app/src/main/java/com/manalejandro/location/MainActivity.kt b/app/src/main/java/com/manalejandro/location/MainActivity.kt new file mode 100644 index 0000000..2ef45aa --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/MainActivity.kt @@ -0,0 +1,344 @@ +package com.manalejandro.location + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.manalejandro.location.ui.theme.LocationTheme +import java.text.SimpleDateFormat +import java.util.* + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LocationTheme { + LocationApp() + } + } + } +} + +@Composable +fun LocationApp() { + val context = LocalContext.current + val locationService = remember { LocationService(context) } + val viewModel: LocationViewModel = viewModel { LocationViewModel(locationService) } + + val uiState by viewModel.uiState.collectAsState() + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Título + Text( + text = "Simulador de Ubicación", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Text( + text = "Esta aplicación simula ubicaciones sin necesidad de permisos reales", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + // Sección de ubicación actual/simulada + CurrentLocationSection( + uiState = uiState, + onGetLastLocation = viewModel::getLastKnownLocation, + onGetCurrentLocation = viewModel::getCurrentLocation + ) + + // Sección de ubicaciones populares + PopularLocationsSection( + uiState = uiState, + onSelectLocation = viewModel::setPopularLocation, + onGenerateRandom = viewModel::generateRandomLocation + ) + + HorizontalDivider() + + // Sección para simular ubicación personalizada + CustomLocationSection( + uiState = uiState, + onMockLocation = viewModel::mockLocation + ) + + // Mostrar errores + uiState.error?.let { error -> + ErrorSection( + error = error, + onDismiss = viewModel::clearError + ) + } + } + } +} + +@Composable +fun CurrentLocationSection( + uiState: LocationState, + onGetLastLocation: () -> Unit, + onGetCurrentLocation: () -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Ubicación Simulada", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onGetLastLocation, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Última de Google") + } + + Button( + onClick = onGetCurrentLocation, + enabled = !uiState.isLoading, + modifier = Modifier.weight(1f) + ) { + Text("Simular Nueva") + } + } + + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + uiState.currentLocation?.let { location -> + LocationDisplay( + title = if (location.provider == "google_provider") + "Última ubicación guardada por Google" + else + "Ubicación Actual", + location = location + ) + } + } + } +} + +@Composable +fun PopularLocationsSection( + uiState: LocationState, + onSelectLocation: (android.location.Location) -> Unit, + onGenerateRandom: (android.location.Location) -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Ubicaciones Populares", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.popularLocations) { (cityName, location) -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Button( + onClick = { onSelectLocation(location) }, + enabled = !uiState.isLoading + ) { + Text(cityName) + } + + OutlinedButton( + onClick = { onGenerateRandom(location) }, + enabled = !uiState.isLoading, + modifier = Modifier.width(100.dp) + ) { + Text("Aleatorio", fontSize = 10.sp) + } + } + } + } + + if (uiState.popularLocations.isNotEmpty()) { + Text( + text = "Toca una ciudad para ir allí, o 'Aleatorio' para generar una ubicación cercana", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +fun CustomLocationSection( + uiState: LocationState, + onMockLocation: (Double, Double) -> Unit +) { + var latitude by remember { mutableStateOf("") } + var longitude by remember { mutableStateOf("") } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "Ubicación Personalizada", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = latitude, + onValueChange = { latitude = it }, + label = { Text("Latitud") }, + placeholder = { Text("40.4168") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + + OutlinedTextField( + value = longitude, + onValueChange = { longitude = it }, + label = { Text("Longitud") }, + placeholder = { Text("-3.7038") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + modifier = Modifier.weight(1f) + ) + } + + Button( + onClick = { + val lat = latitude.toDoubleOrNull() + val lng = longitude.toDoubleOrNull() + if (lat != null && lng != null) { + onMockLocation(lat, lng) + } + }, + enabled = latitude.isNotBlank() && longitude.isNotBlank() && !uiState.isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Establecer Ubicación Personalizada") + } + + uiState.mockedLocation?.let { location -> + LocationDisplay( + title = "Ubicación Guardada", + location = location + ) + } + } + } +} + +@Composable +fun LocationDisplay(title: String, location: android.location.Location) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = "Latitud: ${String.format("%.6f", location.latitude)}", + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = "Longitud: ${String.format("%.6f", location.longitude)}", + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + if (location.hasAccuracy()) { + Text( + text = "Precisión: ${String.format("%.1f", location.accuracy)} metros", + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + Text( + text = "Hora: ${SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.getDefault()).format(Date(location.time))}", + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } +} + +@Composable +fun ErrorSection(error: String, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onDismiss) { + Text( + text = "Cerrar", + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/location/ui/theme/Color.kt b/app/src/main/java/com/manalejandro/location/ui/theme/Color.kt new file mode 100644 index 0000000..41a8955 --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.manalejandro.location.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/location/ui/theme/Theme.kt b/app/src/main/java/com/manalejandro/location/ui/theme/Theme.kt new file mode 100644 index 0000000..db0ae33 --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.manalejandro.location.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +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.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun LocationTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/manalejandro/location/ui/theme/Type.kt b/app/src/main/java/com/manalejandro/location/ui/theme/Type.kt new file mode 100644 index 0000000..9ecdbe6 --- /dev/null +++ b/app/src/main/java/com/manalejandro/location/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.manalejandro.location.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/images/location.png b/app/src/main/res/images/location.png new file mode 100644 index 0000000..9d077b1 Binary files /dev/null and b/app/src/main/res/images/location.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..d52f15c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1685ca6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1c7de51 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..e51a121 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d86853d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7ed4d18 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..4a82ab1 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..2878bf5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5629f62 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..3bbe15a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..00609b5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b3c4a03 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..e4403e9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..26f40c0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d5fbfd9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..10c1ef7 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F0F3F7 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a981fdb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + location + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..7a8dce6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +