From 9c0e2fd071151415fd6d0462ddb56b7bf7899864 Mon Sep 17 00:00:00 2001 From: inhale-dir Date: Thu, 12 Dec 2024 20:52:23 +0100 Subject: [PATCH] supi --- .../example/tank/data/SettingsDataStore.kt | 67 +++++++++++++- .../java/com/example/tank/di/NetworkModule.kt | 27 +++--- .../example/tank/ui/screens/ApiKeyScreen.kt | 79 +++++++++++++++++ .../example/tank/ui/screens/LoginScreen.kt | 87 +++++++++++++++++++ .../com/example/tank/ui/screens/MainScreen.kt | 53 ++++++++++- .../example/tank/ui/screens/PricesScreen.kt | 62 +++++++++---- 6 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/example/tank/ui/screens/ApiKeyScreen.kt create mode 100644 app/src/main/java/com/example/tank/ui/screens/LoginScreen.kt diff --git a/app/src/main/java/com/example/tank/data/SettingsDataStore.kt b/app/src/main/java/com/example/tank/data/SettingsDataStore.kt index 742dce0..213dad6 100644 --- a/app/src/main/java/com/example/tank/data/SettingsDataStore.kt +++ b/app/src/main/java/com/example/tank/data/SettingsDataStore.kt @@ -5,12 +5,20 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import com.example.tank.di.NetworkModule -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume private val Context.dataStore by preferencesDataStore(name = "settings") class SettingsDataStore(private val context: Context) { + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) + private object PreferencesKeys { val LAT_KEY = doublePreferencesKey("lat") val LNG_KEY = doublePreferencesKey("lng") @@ -49,7 +57,7 @@ class SettingsDataStore(private val context: Context) { } val apiKey = context.dataStore.data.map { preferences -> - preferences[PreferencesKeys.API_KEY] ?: NetworkModule.DEFAULT_API_KEY + preferences[PreferencesKeys.API_KEY] ?: "" } val favoriteStations = context.dataStore.data.map { preferences -> @@ -92,6 +100,7 @@ class SettingsDataStore(private val context: Context) { suspend fun saveApiKey(apiKey: String) { context.dataStore.edit { preferences -> preferences[PreferencesKeys.API_KEY] = apiKey + NetworkModule.updateApiKey(apiKey) } } @@ -117,4 +126,56 @@ class SettingsDataStore(private val context: Context) { preferences[PreferencesKeys.LOCATION_MODE_KEY] = mode } } + + suspend fun getCurrentLocation(): Pair? { + return if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED) { + try { + suspendCancellableCoroutine { continuation -> + fusedLocationClient + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { location -> + if (location != null) { + continuation.resume(Pair(location.latitude, location.longitude)) + } else { + continuation.resume(null) + } + } + .addOnFailureListener { + continuation.resume(null) + } + }?.also { (lat, lng) -> + saveLocation(lat, lng, "gps") + } + } catch (e: Exception) { + null + } + } else { + null + } + } + + suspend fun getLocation(): Pair { + val mode = context.dataStore.data.map { it[PreferencesKeys.LOCATION_MODE_KEY] ?: "static" } + .first() + + return when (mode) { + "gps" -> { + getCurrentLocation() ?: context.dataStore.data.map { preferences -> + Pair( + preferences[PreferencesKeys.GPS_LAT_KEY] ?: preferences[PreferencesKeys.STATIC_LAT_KEY] ?: 52.520008, + preferences[PreferencesKeys.GPS_LNG_KEY] ?: preferences[PreferencesKeys.STATIC_LNG_KEY] ?: 13.404954 + ) + }.first() + } + else -> context.dataStore.data.map { preferences -> + Pair( + preferences[PreferencesKeys.STATIC_LAT_KEY] ?: 52.520008, + preferences[PreferencesKeys.STATIC_LNG_KEY] ?: 13.404954 + ) + }.first() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/tank/di/NetworkModule.kt b/app/src/main/java/com/example/tank/di/NetworkModule.kt index 1574860..d0a481c 100644 --- a/app/src/main/java/com/example/tank/di/NetworkModule.kt +++ b/app/src/main/java/com/example/tank/di/NetworkModule.kt @@ -1,28 +1,21 @@ package com.example.tank.di -import com.example.tank.BuildConfig import com.example.tank.data.api.TankerkoenigApi -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor object NetworkModule { private const val BASE_URL = "https://creativecommons.tankerkoenig.de/json/" - const val DEFAULT_API_KEY = "9336f42d-2ebe-3a41-7100-11861d00ad04" - - private var currentApiKey: String = DEFAULT_API_KEY - - fun updateApiKey(newKey: String) { - currentApiKey = newKey + private var apiKey: String = "" + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY } - - fun getApiKey(): String = currentApiKey private val okHttpClient = OkHttpClient.Builder() - .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY - }) + .addInterceptor(loggingInterceptor) .build() private val retrofit = Retrofit.Builder() @@ -32,4 +25,10 @@ object NetworkModule { .build() val tankerkoenigApi: TankerkoenigApi = retrofit.create(TankerkoenigApi::class.java) + + fun getApiKey(): String = apiKey + + fun updateApiKey(newApiKey: String) { + apiKey = newApiKey + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/tank/ui/screens/ApiKeyScreen.kt b/app/src/main/java/com/example/tank/ui/screens/ApiKeyScreen.kt new file mode 100644 index 0000000..1f5be46 --- /dev/null +++ b/app/src/main/java/com/example/tank/ui/screens/ApiKeyScreen.kt @@ -0,0 +1,79 @@ +package com.example.tank.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import com.example.tank.data.SettingsDataStore +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApiKeyScreen(onApiKeySubmitted: () -> Unit) { + var apiKey by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + val context = LocalContext.current + val settingsDataStore = remember { SettingsDataStore(context) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Welcome to Tank App", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + Text( + text = "Please enter your Tankerkoenig API key to continue", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp) + ) + + OutlinedTextField( + value = apiKey, + onValueChange = { + apiKey = it + isError = false + }, + label = { Text("API Key") }, + isError = isError, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + supportingText = if (isError) { + { Text("API key is required") } + } else { + { Text("Get your API key from https://creativecommons.tankerkoenig.de") } + } + ) + + Button( + onClick = { + if (apiKey.isBlank()) { + isError = true + } else { + scope.launch { + settingsDataStore.saveApiKey(apiKey) + onApiKeySubmitted() + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Continue") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tank/ui/screens/LoginScreen.kt b/app/src/main/java/com/example/tank/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..f3b3f69 --- /dev/null +++ b/app/src/main/java/com/example/tank/ui/screens/LoginScreen.kt @@ -0,0 +1,87 @@ +package com.example.tank.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.tank.data.SettingsDataStore +import com.example.tank.di.NetworkModule +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen(onLoginSuccess: () -> Unit) { + var apiKey by remember { mutableStateOf("") } + var isError by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + val context = LocalContext.current + val settingsDataStore = remember { SettingsDataStore(context) } + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Welcome to Tank App", + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + OutlinedTextField( + value = apiKey, + onValueChange = { + apiKey = it + isError = false + }, + label = { Text("API Key") }, + isError = isError, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) + + if (isError) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + Text( + text = "Get your API key from https://creativecommons.tankerkoenig.de", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Button( + onClick = { + if (apiKey.isBlank()) { + isError = true + errorMessage = "API key is required" + } else { + scope.launch { + settingsDataStore.saveApiKey(apiKey) + NetworkModule.updateApiKey(apiKey) + onLoginSuccess() + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Login") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tank/ui/screens/MainScreen.kt b/app/src/main/java/com/example/tank/ui/screens/MainScreen.kt index 0e7c77e..d0b6163 100644 --- a/app/src/main/java/com/example/tank/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/example/tank/ui/screens/MainScreen.kt @@ -1,18 +1,57 @@ package com.example.tank.ui.screens +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.foundation.layout.padding +import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.example.tank.data.SettingsDataStore +import com.example.tank.di.NetworkModule +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +@Composable +fun MainScreen() { + val context = LocalContext.current + val settingsDataStore = remember { SettingsDataStore(context) } + var hasApiKey by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + // Check if API key exists and initialize NetworkModule + LaunchedEffect(Unit) { + val apiKey = settingsDataStore.apiKey.first() + if (apiKey.isNotBlank()) { + NetworkModule.updateApiKey(apiKey) + hasApiKey = true + } else { + hasApiKey = false + } + } + + when (hasApiKey) { + true -> MainContent() + false -> LoginScreen( + onLoginSuccess = { + scope.launch { + hasApiKey = true + } + } + ) + null -> LoadingScreen() + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainScreen() { +private fun MainContent() { val navController = rememberNavController() Scaffold( @@ -41,4 +80,14 @@ fun MainScreen() { composable("map") { MapScreen(navController = navController) } } } +} + +@Composable +private fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/tank/ui/screens/PricesScreen.kt b/app/src/main/java/com/example/tank/ui/screens/PricesScreen.kt index 5126093..cc51bf7 100644 --- a/app/src/main/java/com/example/tank/ui/screens/PricesScreen.kt +++ b/app/src/main/java/com/example/tank/ui/screens/PricesScreen.kt @@ -39,6 +39,10 @@ import android.content.pm.PackageManager import androidx.core.content.ContextCompat import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationResult +import android.os.Looper @OptIn(ExperimentalMaterialApi::class) @Composable @@ -71,6 +75,28 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) { LocationServices.getFusedLocationProviderClient(context) } + val locationRequest = remember { + LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000) + .setWaitForAccurateLocation(true) + .setMinUpdateIntervalMillis(500) + .setMaxUpdateDelayMillis(1000) + .build() + } + + val locationCallback = remember { + object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { location -> + Log.d("PricesScreen", "Location update received: ${location.latitude}, ${location.longitude}") + scope.launch { + settingsDataStore.saveLocation(location.latitude, location.longitude) + viewModel.loadStations(location.latitude, location.longitude, selectedRadius) + } + } + } + } + } + // Add radius collection LaunchedEffect(Unit) { settingsDataStore.selectedRadius.collect { radius -> @@ -81,29 +107,26 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) { // Add location mode collection LaunchedEffect(Unit) { settingsDataStore.locationMode.collect { mode -> + Log.d("PricesScreen", "Location mode changed to: $mode") locationMode = mode // Reload stations when location mode changes when { mode == "gps" && ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> { + Log.d("PricesScreen", "GPS mode and permission granted") try { - fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) - .addOnSuccessListener { location -> - if (location != null) { - scope.launch { - settingsDataStore.saveLocation(location.latitude, location.longitude) - viewModel.loadStations(location.latitude, location.longitude, selectedRadius) - } - } else { - // Fallback to saved location if GPS returns null - scope.launch { - val savedLocation = settingsDataStore.selectedLocation.first() - viewModel.loadStations(savedLocation.first, savedLocation.second, selectedRadius) - } - } - } + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + context.mainLooper + ).addOnSuccessListener { + Log.d("PricesScreen", "Location updates requested successfully") + }.addOnFailureListener { exception -> + Log.e("PricesScreen", "Failed to request location updates", exception) + } } catch (e: SecurityException) { + Log.e("PricesScreen", "Security exception when getting location", e) // Fallback to saved location scope.launch { val savedLocation = settingsDataStore.selectedLocation.first() @@ -112,9 +135,11 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) { } } else -> { + Log.d("PricesScreen", "Using static mode or GPS permission denied") // Use saved location for static mode or when GPS is not available scope.launch { val savedLocation = settingsDataStore.selectedLocation.first() + Log.d("PricesScreen", "Using saved location: ${savedLocation.first}, ${savedLocation.second}") viewModel.loadStations(savedLocation.first, savedLocation.second, selectedRadius) } } @@ -122,6 +147,13 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) { } } + // Add cleanup + DisposableEffect(Unit) { + onDispose { + fusedLocationClient.removeLocationUpdates(locationCallback) + } + } + val pullRefreshState = rememberPullRefreshState( refreshing = isLoading, onRefresh = {