This commit is contained in:
inhale-dir
2024-12-12 20:52:23 +01:00
parent 848c91379a
commit 9c0e2fd071
6 changed files with 341 additions and 34 deletions
@@ -5,12 +5,20 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.* import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.example.tank.di.NetworkModule import com.example.tank.di.NetworkModule
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.map 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") private val Context.dataStore by preferencesDataStore(name = "settings")
class SettingsDataStore(private val context: Context) { class SettingsDataStore(private val context: Context) {
private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
private object PreferencesKeys { private object PreferencesKeys {
val LAT_KEY = doublePreferencesKey("lat") val LAT_KEY = doublePreferencesKey("lat")
val LNG_KEY = doublePreferencesKey("lng") val LNG_KEY = doublePreferencesKey("lng")
@@ -49,7 +57,7 @@ class SettingsDataStore(private val context: Context) {
} }
val apiKey = context.dataStore.data.map { preferences -> 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 -> val favoriteStations = context.dataStore.data.map { preferences ->
@@ -92,6 +100,7 @@ class SettingsDataStore(private val context: Context) {
suspend fun saveApiKey(apiKey: String) { suspend fun saveApiKey(apiKey: String) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[PreferencesKeys.API_KEY] = apiKey preferences[PreferencesKeys.API_KEY] = apiKey
NetworkModule.updateApiKey(apiKey)
} }
} }
@@ -117,4 +126,56 @@ class SettingsDataStore(private val context: Context) {
preferences[PreferencesKeys.LOCATION_MODE_KEY] = mode preferences[PreferencesKeys.LOCATION_MODE_KEY] = mode
} }
} }
suspend fun getCurrentLocation(): Pair<Double, Double>? {
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<Double, Double> {
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()
}
}
} }
@@ -1,28 +1,21 @@
package com.example.tank.di package com.example.tank.di
import com.example.tank.BuildConfig
import com.example.tank.data.api.TankerkoenigApi import com.example.tank.data.api.TankerkoenigApi
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
object NetworkModule { object NetworkModule {
private const val BASE_URL = "https://creativecommons.tankerkoenig.de/json/" private const val BASE_URL = "https://creativecommons.tankerkoenig.de/json/"
const val DEFAULT_API_KEY = "9336f42d-2ebe-3a41-7100-11861d00ad04" private var apiKey: String = ""
private var currentApiKey: String = DEFAULT_API_KEY private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
fun updateApiKey(newKey: String) {
currentApiKey = newKey
} }
fun getApiKey(): String = currentApiKey
private val okHttpClient = OkHttpClient.Builder() private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { .addInterceptor(loggingInterceptor)
level = HttpLoggingInterceptor.Level.BODY
})
.build() .build()
private val retrofit = Retrofit.Builder() private val retrofit = Retrofit.Builder()
@@ -32,4 +25,10 @@ object NetworkModule {
.build() .build()
val tankerkoenigApi: TankerkoenigApi = retrofit.create(TankerkoenigApi::class.java) val tankerkoenigApi: TankerkoenigApi = retrofit.create(TankerkoenigApi::class.java)
fun getApiKey(): String = apiKey
fun updateApiKey(newApiKey: String) {
apiKey = newApiKey
}
} }
@@ -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")
}
}
}
@@ -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")
}
}
}
@@ -1,18 +1,57 @@
package com.example.tank.ui.screens 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.Icons
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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<Boolean?>(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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen() { private fun MainContent() {
val navController = rememberNavController() val navController = rememberNavController()
Scaffold( Scaffold(
@@ -42,3 +81,13 @@ fun MainScreen() {
} }
} }
} }
@Composable
private fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@@ -39,6 +39,10 @@ import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority 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) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -71,6 +75,28 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) {
LocationServices.getFusedLocationProviderClient(context) 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 // Add radius collection
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
settingsDataStore.selectedRadius.collect { radius -> settingsDataStore.selectedRadius.collect { radius ->
@@ -81,29 +107,26 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) {
// Add location mode collection // Add location mode collection
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
settingsDataStore.locationMode.collect { mode -> settingsDataStore.locationMode.collect { mode ->
Log.d("PricesScreen", "Location mode changed to: $mode")
locationMode = mode locationMode = mode
// Reload stations when location mode changes // Reload stations when location mode changes
when { when {
mode == "gps" && mode == "gps" &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED -> { PackageManager.PERMISSION_GRANTED -> {
Log.d("PricesScreen", "GPS mode and permission granted")
try { try {
fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) fusedLocationClient.requestLocationUpdates(
.addOnSuccessListener { location -> locationRequest,
if (location != null) { locationCallback,
scope.launch { context.mainLooper
settingsDataStore.saveLocation(location.latitude, location.longitude) ).addOnSuccessListener {
viewModel.loadStations(location.latitude, location.longitude, selectedRadius) Log.d("PricesScreen", "Location updates requested successfully")
} }.addOnFailureListener { exception ->
} else { Log.e("PricesScreen", "Failed to request location updates", exception)
// Fallback to saved location if GPS returns null
scope.launch {
val savedLocation = settingsDataStore.selectedLocation.first()
viewModel.loadStations(savedLocation.first, savedLocation.second, selectedRadius)
}
}
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.e("PricesScreen", "Security exception when getting location", e)
// Fallback to saved location // Fallback to saved location
scope.launch { scope.launch {
val savedLocation = settingsDataStore.selectedLocation.first() val savedLocation = settingsDataStore.selectedLocation.first()
@@ -112,9 +135,11 @@ fun PricesScreen(viewModel: StationsViewModel = viewModel()) {
} }
} }
else -> { else -> {
Log.d("PricesScreen", "Using static mode or GPS permission denied")
// Use saved location for static mode or when GPS is not available // Use saved location for static mode or when GPS is not available
scope.launch { scope.launch {
val savedLocation = settingsDataStore.selectedLocation.first() val savedLocation = settingsDataStore.selectedLocation.first()
Log.d("PricesScreen", "Using saved location: ${savedLocation.first}, ${savedLocation.second}")
viewModel.loadStations(savedLocation.first, savedLocation.second, selectedRadius) 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( val pullRefreshState = rememberPullRefreshState(
refreshing = isLoading, refreshing = isLoading,
onRefresh = { onRefresh = {