supi
This commit is contained in:
@@ -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<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
|
||||
|
||||
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 apiKey: String = ""
|
||||
|
||||
private var currentApiKey: String = DEFAULT_API_KEY
|
||||
|
||||
fun updateApiKey(newKey: String) {
|
||||
currentApiKey = newKey
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
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<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)
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
private fun MainContent() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
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 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 = {
|
||||
|
||||
Reference in New Issue
Block a user