commit 8a23889fba362671e9b84b81bc91d1d5c8aa1b00 Author: inhale-dir Date: Sun Dec 22 20:29:18 2024 +0100 init commit 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..e03b447 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,84 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "inhale.rip.snowtime" + compileSdk = 35 + + defaultConfig { + applicationId = "inhale.rip.snowtime" + minSdk = 26 + targetSdk = 34 + 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 + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.02.00") + implementation(composeBom) + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(composeBom) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.compose.material:material-icons-extended:1.6.1") + + // Remove Google Maps dependencies and add osmdroid + implementation("org.osmdroid:osmdroid-android:6.1.18") + + // Retrofit for API calls + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + + // DataStore + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // Add pull-to-refresh support + implementation("androidx.compose.material:material") + + // Add Google Play Services Location + implementation("com.google.android.gms:play-services-location:21.2.0") +} \ 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/inhale/rip/snowtime/ExampleInstrumentedTest.kt b/app/src/androidTest/java/inhale/rip/snowtime/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..80cc2b6 --- /dev/null +++ b/app/src/androidTest/java/inhale/rip/snowtime/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package inhale.rip.snowtime + +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("inhale.rip.snowtime", 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..862ca51 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/MainActivity.kt b/app/src/main/java/inhale/rip/snowtime/MainActivity.kt new file mode 100644 index 0000000..34f1480 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/MainActivity.kt @@ -0,0 +1,322 @@ +package inhale.rip.snowtime + +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.LazyColumn +import androidx.compose.foundation.lazy.items +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.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import android.app.Application +import android.util.Log +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import inhale.rip.snowtime.data.LocationMode +import inhale.rip.snowtime.data.WeatherData +import inhale.rip.snowtime.ui.screens.MapSelectionScreen +import inhale.rip.snowtime.ui.screens.SettingsScreen +import inhale.rip.snowtime.ui.theme.SnowtimeTheme +import inhale.rip.snowtime.ui.theme.spacing +import inhale.rip.snowtime.viewmodel.MainViewModel +import inhale.rip.snowtime.viewmodel.SettingsViewModel +import android.content.Context +import android.os.Build +import android.os.Vibrator +import android.os.VibrationEffect +import java.text.SimpleDateFormat +import java.util.* +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.activity.compose.BackHandler +import android.Manifest +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices + +private const val TAG = "MainActivity" + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + SnowtimeTheme { + LocationApp() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +fun LocationApp() { + var showSettings by remember { mutableStateOf(false) } + var showMap by remember { mutableStateOf(false) } + val settingsViewModel: SettingsViewModel = viewModel( + factory = ViewModelProvider.AndroidViewModelFactory.getInstance( + LocalContext.current.applicationContext as Application + ) + ) + val mainViewModel: MainViewModel = viewModel( + factory = ViewModelProvider.AndroidViewModelFactory.getInstance( + LocalContext.current.applicationContext as Application + ) + ) + + val settings by settingsViewModel.settings.collectAsState() + val snowLocations by mainViewModel.snowLocations.collectAsState() + val isLoading by mainViewModel.isLoading.collectAsState() + val lastRefreshTime by mainViewModel.lastRefreshTime.collectAsState() + val context = LocalContext.current + val pullRefreshState = rememberPullRefreshState( + refreshing = isLoading, + onRefresh = { + mainViewModel.refreshData() + // Trigger haptic feedback + (context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.let { vibrator -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(50) + } + } + } + ) + + var currentLocation by remember { mutableStateOf(null) } + val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } + + val locationPermissionRequest = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + when { + permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) || + permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { + // Permission granted, get location + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + currentLocation = location + } + } + else -> { + // Show some UI to explain why location is useful + } + } + } + + // Request location permission on launch + LaunchedEffect(Unit) { + when { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED -> { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + currentLocation = location + } + } + else -> { + locationPermissionRequest.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + } + } + + // Add BackHandler for settings screen + BackHandler(enabled = showSettings || showMap) { + when { + showMap -> showMap = false + showSettings -> showSettings = false + } + } + + when { + showMap -> { + MapSelectionScreen( + onNavigateBack = { + showMap = false + Log.d(TAG, "Navigating back from map selection") + }, + viewModel = settingsViewModel + ) + } + showSettings -> { + SettingsScreen( + onNavigateBack = { + showSettings = false + Log.d(TAG, "Navigating back from settings") + }, + onOpenMap = { + showMap = true + Log.d(TAG, "Opening map selection") + }, + viewModel = settingsViewModel + ) + } + else -> { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(R.string.app_name)) + currentLocation?.let { location -> + Text( + text = "📍 ${String.format("%.4f, %.4f", + location.latitude, location.longitude)}", + style = MaterialTheme.typography.bodySmall + ) + } + lastRefreshTime?.let { time -> + Text( + text = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + .format(Date(time)), + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + actions = { + IconButton( + onClick = { + showSettings = true + Log.d(TAG, "Settings button clicked") + } + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.settings) + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState), + color = MaterialTheme.colorScheme.background + ) { + when { + isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + settings.locationMode == LocationMode.STATIC && settings.staticLocation == null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Please select a location in settings", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp) + ) + } + } + snowLocations.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No snow locations found", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp) + ) + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.medium, + vertical = MaterialTheme.spacing.small + ) + ) { + items(snowLocations) { location -> + SnowLocationItem(location) + } + } + } + } + } + PullRefreshIndicator( + refreshing = isLoading, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + } + } +} + +private fun formatTimestamp(timestamp: Long): String { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) +} + +@Composable +fun SnowLocationItem(location: WeatherData) { + val spacing = MaterialTheme.spacing + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = spacing.small, vertical = spacing.extraSmall) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.medium) + ) { + Text( + text = location.name, + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(spacing.small)) + Text( + text = "Temperature: ${location.main.temp}°C", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Snow (last hour): ${location.snow?.lastHour ?: 0} mm", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Snow (last 3 hours): ${location.snow?.lastThreeHours ?: 0} mm", + style = MaterialTheme.typography.bodyMedium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/data/Settings.kt b/app/src/main/java/inhale/rip/snowtime/data/Settings.kt new file mode 100644 index 0000000..3c000a3 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/data/Settings.kt @@ -0,0 +1,15 @@ +package inhale.rip.snowtime.data + +import org.osmdroid.util.GeoPoint + +enum class LocationMode { + GPS, STATIC +} + +data class Settings( + val locationMode: LocationMode = LocationMode.GPS, + val searchRadius: Int = 10, // in kilometers + val staticLocation: GeoPoint? = null, + val timeRange: TimeRange = TimeRange.HOURS_3, + val apiKey: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/data/WeatherData.kt b/app/src/main/java/inhale/rip/snowtime/data/WeatherData.kt new file mode 100644 index 0000000..1fccc59 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/data/WeatherData.kt @@ -0,0 +1,45 @@ +package inhale.rip.snowtime.data + +import com.google.gson.annotations.SerializedName +import java.util.Date + +data class WeatherResponse( + val list: List, + val count: Int +) + +data class WeatherData( + val id: Long, + val name: String, + val coord: Coordinates, + val snow: Snow?, + @SerializedName("dt") val timestamp: Long, + val main: MainWeather +) + +data class Coordinates( + @SerializedName("lat") val latitude: Double, + @SerializedName("lon") val longitude: Double +) + +data class Snow( + @SerializedName("1h") val lastHour: Double? = null, + @SerializedName("3h") val lastThreeHours: Double? = null +) + +data class MainWeather( + val temp: Double, + @SerializedName("feels_like") val feelsLike: Double, + val humidity: Int +) + +enum class TimeRange(val value: Long) { + MINUTES_30(30 * 60 * 1000), + HOURS_1(60 * 60 * 1000), + HOURS_3(3 * 60 * 60 * 1000), + HOURS_6(6 * 60 * 60 * 1000), + HOURS_12(12 * 60 * 60 * 1000), + DAYS_1(24 * 60 * 60 * 1000), + DAYS_2(2 * 24 * 60 * 60 * 1000), + DAYS_3(3 * 24 * 60 * 60 * 1000) +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/network/WeatherApi.kt b/app/src/main/java/inhale/rip/snowtime/network/WeatherApi.kt new file mode 100644 index 0000000..63a1a41 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/network/WeatherApi.kt @@ -0,0 +1,21 @@ +package inhale.rip.snowtime.network + +import inhale.rip.snowtime.data.WeatherResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface WeatherApi { + @GET("data/2.5/find") + suspend fun findSnowLocations( + @Query("lat") latitude: Double, + @Query("lon") longitude: Double, + @Query("cnt") count: Int = 50, + @Query("radius") radiusKm: Int, + @Query("appid") apiKey: String, + @Query("units") units: String = "metric" + ): WeatherResponse + + companion object { + const val BASE_URL = "https://api.openweathermap.org/" + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/repository/SettingsRepository.kt b/app/src/main/java/inhale/rip/snowtime/repository/SettingsRepository.kt new file mode 100644 index 0000000..bc8d0a0 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/repository/SettingsRepository.kt @@ -0,0 +1,76 @@ +package inhale.rip.snowtime.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStore +import inhale.rip.snowtime.data.LocationMode +import inhale.rip.snowtime.data.Settings +import inhale.rip.snowtime.data.TimeRange +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import org.osmdroid.util.GeoPoint +import java.io.IOException + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class SettingsRepository(private val context: Context) { + private object PreferencesKeys { + val LOCATION_MODE = stringPreferencesKey("location_mode") + val SEARCH_RADIUS = intPreferencesKey("search_radius") + val STATIC_LOCATION_LAT = doublePreferencesKey("static_location_lat") + val STATIC_LOCATION_LON = doublePreferencesKey("static_location_lon") + val TIME_RANGE = stringPreferencesKey("time_range") + val API_KEY = stringPreferencesKey("api_key") + } + + val settings: Flow = context.dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + val locationMode = LocationMode.valueOf( + preferences[PreferencesKeys.LOCATION_MODE] ?: LocationMode.GPS.name + ) + val searchRadius = preferences[PreferencesKeys.SEARCH_RADIUS] ?: 10 + val staticLocation = if ( + preferences.contains(PreferencesKeys.STATIC_LOCATION_LAT) && + preferences.contains(PreferencesKeys.STATIC_LOCATION_LON) + ) { + GeoPoint( + preferences[PreferencesKeys.STATIC_LOCATION_LAT] ?: 0.0, + preferences[PreferencesKeys.STATIC_LOCATION_LON] ?: 0.0 + ) + } else null + val timeRange = TimeRange.valueOf( + preferences[PreferencesKeys.TIME_RANGE] ?: TimeRange.HOURS_3.name + ) + val apiKey = preferences[PreferencesKeys.API_KEY] ?: "" + + Settings( + locationMode = locationMode, + searchRadius = searchRadius, + staticLocation = staticLocation, + timeRange = timeRange, + apiKey = apiKey + ) + } + + suspend fun updateSettings(settings: Settings) { + context.dataStore.edit { preferences -> + preferences[PreferencesKeys.LOCATION_MODE] = settings.locationMode.name + preferences[PreferencesKeys.SEARCH_RADIUS] = settings.searchRadius + settings.staticLocation?.let { location -> + preferences[PreferencesKeys.STATIC_LOCATION_LAT] = location.latitude + preferences[PreferencesKeys.STATIC_LOCATION_LON] = location.longitude + } + preferences[PreferencesKeys.TIME_RANGE] = settings.timeRange.name + preferences[PreferencesKeys.API_KEY] = settings.apiKey + } + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/repository/WeatherRepository.kt b/app/src/main/java/inhale/rip/snowtime/repository/WeatherRepository.kt new file mode 100644 index 0000000..8263c33 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/repository/WeatherRepository.kt @@ -0,0 +1,119 @@ +package inhale.rip.snowtime.repository + +import android.util.Log +import inhale.rip.snowtime.data.* +import inhale.rip.snowtime.network.WeatherApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.osmdroid.util.GeoPoint +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +private const val TAG = "WeatherRepository" + +class WeatherRepository { + private val api = Retrofit.Builder() + .baseUrl(WeatherApi.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(WeatherApi::class.java) + + fun getSnowLocations( + location: GeoPoint, + radiusKm: Int, + timeRange: TimeRange, + apiKey: String + ): Flow> = flow { + if (apiKey.isBlank()) { + Log.e(TAG, "API key is not set") + emit(getMockData(location)) + return@flow + } + + try { + val response = api.findSnowLocations( + latitude = location.latitude, + longitude = location.longitude, + radiusKm = radiusKm, + apiKey = apiKey.trim() + ) + + val currentTime = System.currentTimeMillis() + val filteredLocations = response.list.filter { weatherData -> + val hasSnow = weatherData.snow?.lastHour != null || weatherData.snow?.lastThreeHours != null + val timestampMs = weatherData.timestamp * 1000L + val isWithinTimeRange = (currentTime - timestampMs) <= timeRange.value + hasSnow && isWithinTimeRange + } + + emit(filteredLocations) + Log.d(TAG, "Found ${filteredLocations.size} locations with snow") + } catch (e: retrofit2.HttpException) { + Log.e(TAG, "Using mock data due to API error: ${e.message()}") + emit(getMockData(location)) + } catch (e: Exception) { + Log.e(TAG, "Using mock data due to error", e) + emit(getMockData(location)) + } + } + + private fun getMockData(centerLocation: GeoPoint): List { + val currentTime = System.currentTimeMillis() / 1000 // Current time in seconds + return listOf( + createMockWeatherData( + id = 1, + name = "Near Your Location", + lat = centerLocation.latitude + 0.1, + lon = centerLocation.longitude + 0.1, + temp = -2.0, + snowLastHour = 5.0, + snowLastThreeHours = 12.0, + timestamp = currentTime + ), + createMockWeatherData( + id = 2, + name = "Mountain Peak", + lat = centerLocation.latitude - 0.2, + lon = centerLocation.longitude - 0.15, + temp = -5.0, + snowLastHour = 8.0, + snowLastThreeHours = 20.0, + timestamp = currentTime + ), + createMockWeatherData( + id = 3, + name = "Ski Resort", + lat = centerLocation.latitude + 0.3, + lon = centerLocation.longitude - 0.2, + temp = -1.0, + snowLastHour = 3.0, + snowLastThreeHours = 7.0, + timestamp = currentTime + ) + ) + } + + private fun createMockWeatherData( + id: Long, + name: String, + lat: Double, + lon: Double, + temp: Double, + snowLastHour: Double, + snowLastThreeHours: Double, + timestamp: Long + ): WeatherData { + return WeatherData( + id = id, + name = name, + coord = Coordinates(latitude = lat, longitude = lon), + snow = Snow(lastHour = snowLastHour, lastThreeHours = snowLastThreeHours), + timestamp = timestamp, + main = MainWeather( + temp = temp, + feelsLike = temp - 2.0, + humidity = 85 + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/ui/screens/MapSelectionScreen.kt b/app/src/main/java/inhale/rip/snowtime/ui/screens/MapSelectionScreen.kt new file mode 100644 index 0000000..edadf02 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/screens/MapSelectionScreen.kt @@ -0,0 +1,121 @@ +package inhale.rip.snowtime.ui.screens + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import inhale.rip.snowtime.R +import inhale.rip.snowtime.viewmodel.SettingsViewModel +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.MapView +import org.osmdroid.views.overlay.MapEventsOverlay +import org.osmdroid.views.overlay.Marker + +private const val TAG = "MapSelectionScreen" +private val DEFAULT_LOCATION = GeoPoint(47.0, 8.0) // Default to center of Switzerland + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapSelectionScreen( + onNavigateBack: () -> Unit, + viewModel: SettingsViewModel +) { + val context = LocalContext.current + var selectedLocation by remember { mutableStateOf(null) } + var mapView by remember { mutableStateOf(null) } + + // Initialize osmdroid configuration + LaunchedEffect(Unit) { + Configuration.getInstance().load(context, context.getSharedPreferences("osmdroid", Context.MODE_PRIVATE)) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.select_location)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.navigate_back) + ) + } + }, + actions = { + IconButton( + onClick = { + selectedLocation?.let { location -> + viewModel.updateStaticLocation(location) + Log.i(TAG, "Selected location: ${location.latitude}, ${location.longitude}") + onNavigateBack() + } + }, + enabled = selectedLocation != null + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.confirm_location) + ) + } + } + ) + } + ) { padding -> + AndroidView( + modifier = Modifier + .fillMaxSize() + .padding(padding), + factory = { context -> + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + controller.setCenter(DEFAULT_LOCATION) + controller.setZoom(8.0) + setMultiTouchControls(true) + + // Add tap overlay + val mapEventsOverlay = MapEventsOverlay(object : MapEventsReceiver { + override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { + selectedLocation = p + overlays.removeAll { it is Marker } + + val marker = Marker(this@apply).apply { + position = p + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + } + overlays.add(marker) + invalidate() + + Log.d(TAG, "Map clicked at: ${p.latitude}, ${p.longitude}") + return true + } + + override fun longPressHelper(p: GeoPoint): Boolean = false + }) + overlays.add(mapEventsOverlay) + mapView = this + } + }, + update = { view -> + // Update logic if needed + } + ) + } + + // Cleanup + DisposableEffect(Unit) { + onDispose { + mapView?.onDetach() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/ui/screens/SettingsScreen.kt b/app/src/main/java/inhale/rip/snowtime/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..feec274 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/screens/SettingsScreen.kt @@ -0,0 +1,172 @@ +package inhale.rip.snowtime.ui.screens + +import android.util.Log +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import inhale.rip.snowtime.R +import inhale.rip.snowtime.data.LocationMode +import inhale.rip.snowtime.ui.theme.spacing +import inhale.rip.snowtime.viewmodel.SettingsViewModel + +private const val TAG = "SettingsScreen" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateBack: () -> Unit, + onOpenMap: () -> Unit, + viewModel: SettingsViewModel = viewModel() +) { + val settings by viewModel.settings.collectAsState() + val spacing = MaterialTheme.spacing + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings)) } + ) + }, + bottomBar = { + Surface( + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = onNavigateBack, + modifier = Modifier + .fillMaxWidth() + .padding(spacing.medium) + ) { + Text(stringResource(R.string.save_and_return)) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(spacing.medium), + verticalArrangement = Arrangement.spacedBy(spacing.medium) + ) { + // API Key Section + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.medium), + verticalArrangement = Arrangement.spacedBy(spacing.small) + ) { + Text( + text = stringResource(R.string.api_key), + style = MaterialTheme.typography.titleMedium + ) + + OutlinedTextField( + value = settings.apiKey, + onValueChange = { viewModel.updateApiKey(it) }, + label = { Text(stringResource(R.string.api_key_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + TextButton( + onClick = { + // Open OpenWeatherMap signup page in browser + // You'll need to implement this + } + ) { + Text(stringResource(R.string.get_api_key)) + } + } + } + + // Location Mode Section + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.medium), + verticalArrangement = Arrangement.spacedBy(spacing.small) + ) { + Text( + text = stringResource(R.string.location_mode), + style = MaterialTheme.typography.titleMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + LocationMode.values().forEach { mode -> + FilterChip( + selected = settings.locationMode == mode, + onClick = { + viewModel.updateLocationMode(mode) + Log.d(TAG, "Location mode chip clicked: $mode") + }, + label = { Text(mode.name) }, + modifier = Modifier.padding(end = spacing.small) + ) + } + } + + Button( + onClick = onOpenMap, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.select_location_on_map)) + } + + settings.staticLocation?.let { location -> + Text( + text = stringResource( + R.string.selected_coordinates, + String.format("%.6f", location.latitude), + String.format("%.6f", location.longitude) + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + // Search Radius Section + Card { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(spacing.medium), + verticalArrangement = Arrangement.spacedBy(spacing.small) + ) { + Text( + text = stringResource(R.string.search_radius), + style = MaterialTheme.typography.titleMedium + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + listOf(5, 10, 15, 20).forEach { radius -> + FilterChip( + selected = settings.searchRadius == radius, + onClick = { + viewModel.updateSearchRadius(radius) + Log.d(TAG, "Search radius chip clicked: $radius km") + }, + label = { Text("$radius km") }, + modifier = Modifier.padding(end = spacing.small) + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/ui/theme/Color.kt b/app/src/main/java/inhale/rip/snowtime/ui/theme/Color.kt new file mode 100644 index 0000000..b007d75 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package inhale.rip.snowtime.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/inhale/rip/snowtime/ui/theme/Spacing.kt b/app/src/main/java/inhale/rip/snowtime/ui/theme/Spacing.kt new file mode 100644 index 0000000..da1b0ca --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/theme/Spacing.kt @@ -0,0 +1,24 @@ +package inhale.rip.snowtime.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +data class Spacing( + val default: Dp = 0.dp, + val extraSmall: Dp = 4.dp, + val small: Dp = 8.dp, + val medium: Dp = 16.dp, + val large: Dp = 24.dp, + val extraLarge: Dp = 32.dp +) + +val LocalSpacing = compositionLocalOf { Spacing() } + +val MaterialTheme.spacing: Spacing + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/ui/theme/Theme.kt b/app/src/main/java/inhale/rip/snowtime/ui/theme/Theme.kt new file mode 100644 index 0000000..456109c --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/theme/Theme.kt @@ -0,0 +1,63 @@ +package inhale.rip.snowtime.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 +import androidx.compose.runtime.CompositionLocalProvider + +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 SnowtimeTheme( + 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 + } + + CompositionLocalProvider( + LocalSpacing provides Spacing() + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/ui/theme/Type.kt b/app/src/main/java/inhale/rip/snowtime/ui/theme/Type.kt new file mode 100644 index 0000000..300b524 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package inhale.rip.snowtime.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/java/inhale/rip/snowtime/viewmodel/MainViewModel.kt b/app/src/main/java/inhale/rip/snowtime/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..8320c85 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/viewmodel/MainViewModel.kt @@ -0,0 +1,69 @@ +package inhale.rip.snowtime.viewmodel + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import inhale.rip.snowtime.data.LocationMode +import inhale.rip.snowtime.data.WeatherData +import inhale.rip.snowtime.repository.SettingsRepository +import inhale.rip.snowtime.repository.WeatherRepository +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +private const val TAG = "MainViewModel" + +class MainViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepository = SettingsRepository(application) + private val weatherRepository = WeatherRepository() + + private val _snowLocations = MutableStateFlow>(emptyList()) + val snowLocations: StateFlow> = _snowLocations.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _lastRefreshTime = MutableStateFlow(null) + val lastRefreshTime: StateFlow = _lastRefreshTime.asStateFlow() + + init { + viewModelScope.launch { + settingsRepository.settings + .filter { it.apiKey.isNotBlank() } + .collect { settings -> + if (settings.locationMode == LocationMode.STATIC && settings.staticLocation != null) { + refreshSnowLocations(settings) + } + } + } + } + + private suspend fun refreshSnowLocations(settings: inhale.rip.snowtime.data.Settings) { + _isLoading.value = true + try { + weatherRepository.getSnowLocations( + location = settings.staticLocation!!, + radiusKm = settings.searchRadius, + timeRange = settings.timeRange, + apiKey = settings.apiKey + ).collect { locations -> + _snowLocations.value = locations + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching snow locations", e) + } finally { + _isLoading.value = false + } + } + + fun refreshData() { + viewModelScope.launch { + settingsRepository.settings.firstOrNull()?.let { settings -> + if (settings.locationMode == LocationMode.STATIC && settings.staticLocation != null) { + refreshSnowLocations(settings) + _lastRefreshTime.value = System.currentTimeMillis() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/inhale/rip/snowtime/viewmodel/SettingsViewModel.kt b/app/src/main/java/inhale/rip/snowtime/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..ab1e087 --- /dev/null +++ b/app/src/main/java/inhale/rip/snowtime/viewmodel/SettingsViewModel.kt @@ -0,0 +1,78 @@ +package inhale.rip.snowtime.viewmodel + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import inhale.rip.snowtime.data.LocationMode +import inhale.rip.snowtime.data.Settings +import inhale.rip.snowtime.data.TimeRange +import inhale.rip.snowtime.repository.SettingsRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.osmdroid.util.GeoPoint + +private const val TAG = "SettingsViewModel" + +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepository = SettingsRepository(application) + + val settings: StateFlow = settingsRepository.settings + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = Settings() + ) + + fun updateLocationMode(mode: LocationMode) { + viewModelScope.launch { + settingsRepository.updateSettings( + settings.value.copy(locationMode = mode).also { + Log.i(TAG, "Location mode updated to: $mode") + } + ) + } + } + + fun updateSearchRadius(radius: Int) { + viewModelScope.launch { + settingsRepository.updateSettings( + settings.value.copy(searchRadius = radius).also { + Log.i(TAG, "Search radius updated to: $radius km") + } + ) + } + } + + fun updateStaticLocation(location: GeoPoint) { + viewModelScope.launch { + settingsRepository.updateSettings( + settings.value.copy(staticLocation = location).also { + Log.i(TAG, "Static location updated to: ${location.latitude}, ${location.longitude}") + } + ) + } + } + + fun updateTimeRange(timeRange: TimeRange) { + viewModelScope.launch { + settingsRepository.updateSettings( + settings.value.copy(timeRange = timeRange).also { + Log.i(TAG, "Time range updated to: $timeRange") + } + ) + } + } + + fun updateApiKey(key: String) { + viewModelScope.launch { + settingsRepository.updateSettings( + settings.value.copy(apiKey = key).also { + Log.i(TAG, "API key updated") + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ 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..c209e78 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_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d 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..4f0f1d6 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_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d 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..948a307 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_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 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..28d4b77 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_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 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..aa7d642 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_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 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/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..df804c4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + snowtime + Settings + Navigate back + Location Mode + Search Radius + Select Location + Confirm Location + Selected Location + Save and Return + Select Location on Map + Selected Location: %1$s, %2$s + OpenWeatherMap API Key + Enter your API key here + Get API Key + \ 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..f1ab44f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +