init commit

This commit is contained in:
inhale-dir 2024-12-22 20:29:18 +01:00
commit 8a23889fba
47 changed files with 1991 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

84
app/build.gradle.kts Normal file
View File

@ -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")
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -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)
}
}

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Snowtime"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Snowtime">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -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<Location?>(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
)
}
}
}

View File

@ -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 = ""
)

View File

@ -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<WeatherData>,
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)
}

View File

@ -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/"
}
}

View File

@ -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<Preferences> 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<Settings> = 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
}
}
}

View File

@ -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<List<WeatherData>> = 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<WeatherData> {
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
)
)
}
}

View File

@ -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<GeoPoint?>(null) }
var mapView by remember { mutableStateOf<MapView?>(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()
}
}
}

View File

@ -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)
)
}
}
}
}
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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
)
}
}

View File

@ -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
)
*/
)

View File

@ -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<List<WeatherData>>(emptyList())
val snowLocations: StateFlow<List<WeatherData>> = _snowLocations.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _lastRefreshTime = MutableStateFlow<Long?>(null)
val lastRefreshTime: StateFlow<Long?> = _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()
}
}
}
}
}

View File

@ -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<Settings> = 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")
}
)
}
}
}

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,16 @@
<resources>
<string name="app_name">snowtime</string>
<string name="settings">Settings</string>
<string name="navigate_back">Navigate back</string>
<string name="location_mode">Location Mode</string>
<string name="search_radius">Search Radius</string>
<string name="select_location">Select Location</string>
<string name="confirm_location">Confirm Location</string>
<string name="selected_location">Selected Location</string>
<string name="save_and_return">Save and Return</string>
<string name="select_location_on_map">Select Location on Map</string>
<string name="selected_coordinates">Selected Location: %1$s, %2$s</string>
<string name="api_key">OpenWeatherMap API Key</string>
<string name="api_key_hint">Enter your API key here</string>
<string name="get_api_key">Get API Key</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Snowtime" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@ -0,0 +1,17 @@
package inhale.rip.snowtime
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

6
build.gradle.kts Normal file
View File

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View File

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

32
gradle/libs.versions.toml Normal file
View File

@ -0,0 +1,32 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.0"
coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.04.01"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sun Dec 22 19:52:47 CET 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

185
gradlew vendored Executable file
View File

@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

24
settings.gradle.kts Normal file
View File

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "snowtime"
include(":app")