diff --git a/wetter/.gitignore b/wetter/.gitignore new file mode 100644 index 0000000..77d60bd --- /dev/null +++ b/wetter/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +.idea/ +*.iml +.kotlin/ diff --git a/wetter/app/build.gradle.kts b/wetter/app/build.gradle.kts new file mode 100644 index 0000000..f0d99c8 --- /dev/null +++ b/wetter/app/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") + id("io.github.takahirom.roborazzi") +} + +android { + namespace = "app.wetter" + compileSdk = 36 + + defaultConfig { + applicationId = "app.wetter" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { compose = true } + testOptions { unitTests { isIncludeAndroidResources = true } } +} + +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } + +ksp { arg("room.schemaLocation", "$projectDir/schemas") } + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2025.10.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.activity:activity-compose:1.11.0") + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") + implementation("androidx.graphics:graphics-shapes:1.0.1") + + implementation("androidx.room:room-runtime:2.7.0") + implementation("androidx.room:room-ktx:2.7.0") + ksp("androidx.room:room-compiler:2.7.0") + implementation("androidx.datastore:datastore-preferences:1.1.7") + implementation("androidx.work:work-runtime-ktx:2.11.0") + implementation("androidx.glance:glance-appwidget:1.1.1") + implementation("androidx.glance:glance-material3:1.1.1") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.15.1") + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1") + testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1") +} + +roborazzi { outputDir.set(rootProject.file("../replica/clone-screens")) } diff --git a/wetter/app/proguard-rules.pro b/wetter/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/wetter/app/schemas/app.wetter.data.WeatherDb/1.json b/wetter/app/schemas/app.wetter.data.WeatherDb/1.json new file mode 100644 index 0000000..ede5838 --- /dev/null +++ b/wetter/app/schemas/app.wetter.data.WeatherDb/1.json @@ -0,0 +1,202 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "0ff0f9b078e342dbc6f970eb96a12dec", + "entities": [ + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `admin` TEXT, `country_code` TEXT NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `timezone` TEXT NOT NULL, `is_current` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "admin", + "columnName": "admin", + "affinity": "TEXT" + }, + { + "fieldPath": "countryCode", + "columnName": "country_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lat", + "columnName": "lat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lon", + "columnName": "lon", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCurrent", + "columnName": "is_current", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_locations_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ] + }, + { + "tableName": "forecast_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`location_id` INTEGER NOT NULL, `fetched_at` INTEGER NOT NULL, `forecast_json` TEXT NOT NULL, `air_json` TEXT, PRIMARY KEY(`location_id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forecastJson", + "columnName": "forecast_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "airJson", + "columnName": "air_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "location_id" + ] + }, + "foreignKeys": [ + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "widget_config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`app_widget_id` INTEGER NOT NULL, `location_id` INTEGER, `style` TEXT NOT NULL, PRIMARY KEY(`app_widget_id`), FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "appWidgetId", + "columnName": "app_widget_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "style", + "columnName": "style", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "app_widget_id" + ] + }, + "indices": [ + { + "name": "index_widget_config_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_widget_config_location_id` ON `${TABLE_NAME}` (`location_id`)" + } + ], + "foreignKeys": [ + { + "table": "locations", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0ff0f9b078e342dbc6f970eb96a12dec')" + ] + } +} \ No newline at end of file diff --git a/wetter/app/src/main/AndroidManifest.xml b/wetter/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..eadf7db --- /dev/null +++ b/wetter/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wetter/app/src/main/java/app/wetter/MainActivity.kt b/wetter/app/src/main/java/app/wetter/MainActivity.kt new file mode 100644 index 0000000..3c16a4a --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/MainActivity.kt @@ -0,0 +1,83 @@ +package app.wetter + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.PowerManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import app.wetter.data.Graph +import app.wetter.data.Place +import app.wetter.data.Settings +import app.wetter.ui.screens.DayScreen +import app.wetter.ui.screens.HomeScreen +import app.wetter.ui.screens.LocationsScreen +import app.wetter.ui.screens.PreviewScreen +import app.wetter.ui.screens.SettingsScreen +import app.wetter.ui.theme.WeatherTheme +import java.time.LocalDate + +sealed interface Route { + data class Home(val locationId: Long? = null) : Route + data object Locations : Route + data class Preview(val place: Place) : Route + data class Day(val locationId: Long, val date: LocalDate) : Route + data object Settings : Route +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + Graph.init(this) + val startLocation = intent.getLongExtra(EXTRA_LOCATION, -1L).takeIf { it >= 0 } + setContent { + val settings by Graph.settings.settings.collectAsState(initial = Settings()) + val stack = remember { mutableStateListOf(Route.Home(startLocation)) } + val push = { r: Route -> stack.add(r); Unit } + val pop = { if (stack.size > 1) stack.removeAt(stack.lastIndex); Unit } + val power = getSystemService(PowerManager::class.java) + val animate = !power.isPowerSaveMode + + WeatherTheme(settings.theme) { + BackHandler(enabled = stack.size > 1, onBack = pop) + AnimatedContent(stack.last(), transitionSpec = { fadeIn() togetherWith fadeOut() }, label = "nav") { route -> + when (route) { + is Route.Home -> HomeScreen( + settings, route.locationId, animate, + onOpenLocations = { push(Route.Locations) }, + onOpenSettings = { push(Route.Settings) }, + onOpenDay = { id, d -> push(Route.Day(id, d)) }, + ) + Route.Locations -> LocationsScreen( + settings, onBack = pop, + onOpen = { id -> stack.clear(); stack.add(Route.Home(id)) }, + onPreview = { push(Route.Preview(it)) }, + ) + is Route.Preview -> PreviewScreen(route.place, settings, onBack = pop, onSaved = { id -> stack.clear(); stack.add(Route.Home(id)) }) + is Route.Day -> DayScreen(route.locationId, route.date, settings, onBack = pop) + Route.Settings -> SettingsScreen(settings, onBack = pop) + } + } + } + } + } + + companion object { + const val EXTRA_LOCATION = "location_id" + fun intent(context: Context, locationId: Long?) = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + if (locationId != null) putExtra(EXTRA_LOCATION, locationId) + } + } +} diff --git a/wetter/app/src/main/java/app/wetter/WetterApp.kt b/wetter/app/src/main/java/app/wetter/WetterApp.kt new file mode 100644 index 0000000..9794ed2 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/WetterApp.kt @@ -0,0 +1,13 @@ +package app.wetter + +import android.app.Application +import app.wetter.data.Graph +import app.wetter.work.RefreshWorker + +class WetterApp : Application() { + override fun onCreate() { + super.onCreate() + Graph.init(this) + RefreshWorker.schedule(this) + } +} diff --git a/wetter/app/src/main/java/app/wetter/data/Db.kt b/wetter/app/src/main/java/app/wetter/data/Db.kt new file mode 100644 index 0000000..64e7b79 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/Db.kt @@ -0,0 +1,120 @@ +package app.wetter.data + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "locations", indices = [Index("sort_order")]) +data class LocationEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val admin: String?, + @androidx.room.ColumnInfo(name = "country_code") val countryCode: String, + val lat: Double, + val lon: Double, + val timezone: String, + @androidx.room.ColumnInfo(name = "is_current") val isCurrent: Boolean = false, + @androidx.room.ColumnInfo(name = "sort_order") val sortOrder: Int = 0, + @androidx.room.ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), + @androidx.room.ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(), +) + +@Entity( + tableName = "forecast_cache", + foreignKeys = [ForeignKey(LocationEntity::class, ["id"], ["location_id"], onDelete = ForeignKey.CASCADE)], +) +data class ForecastCacheEntity( + @PrimaryKey @androidx.room.ColumnInfo(name = "location_id") val locationId: Long, + @androidx.room.ColumnInfo(name = "fetched_at") val fetchedAt: Long, + @androidx.room.ColumnInfo(name = "forecast_json") val forecastJson: String, + @androidx.room.ColumnInfo(name = "air_json") val airJson: String?, +) + +@Entity( + tableName = "widget_config", + foreignKeys = [ForeignKey(LocationEntity::class, ["id"], ["location_id"], onDelete = ForeignKey.SET_NULL)], + indices = [Index("location_id")], +) +data class WidgetConfigEntity( + @PrimaryKey @androidx.room.ColumnInfo(name = "app_widget_id") val appWidgetId: Int, + /** null = follow the current location */ + @androidx.room.ColumnInfo(name = "location_id") val locationId: Long?, + val style: String = "auto", +) + +@Dao +interface WeatherDao { + @Query("SELECT * FROM locations ORDER BY is_current DESC, sort_order, id") + fun locations(): Flow> + + @Query("SELECT * FROM locations ORDER BY is_current DESC, sort_order, id") + suspend fun locationsOnce(): List + + @Query("SELECT * FROM locations WHERE id = :id") + suspend fun location(id: Long): LocationEntity? + + @Query("SELECT * FROM locations WHERE is_current = 1 LIMIT 1") + suspend fun currentLocation(): LocationEntity? + + @Insert + suspend fun insert(location: LocationEntity): Long + + @androidx.room.Update + suspend fun update(location: LocationEntity) + + @Query("DELETE FROM locations WHERE id = :id AND is_current = 0") + suspend fun delete(id: Long) + + @Query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM locations") + suspend fun nextSortOrder(): Int + + @Query("UPDATE locations SET sort_order = :order WHERE id = :id") + suspend fun setSortOrder(id: Long, order: Int) + + @Transaction + suspend fun reorder(ids: List) = ids.forEachIndexed { i, id -> setSortOrder(id, i) } + + @Query("SELECT * FROM forecast_cache WHERE location_id = :id") + fun forecast(id: Long): Flow + + @Query("SELECT * FROM forecast_cache WHERE location_id = :id") + suspend fun forecastOnce(id: Long): ForecastCacheEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun putForecast(entity: ForecastCacheEntity) + + @Upsert + suspend fun putWidget(config: WidgetConfigEntity) + + @Query("SELECT * FROM widget_config WHERE app_widget_id = :id") + suspend fun widget(id: Int): WidgetConfigEntity? + + @Query("DELETE FROM widget_config WHERE app_widget_id = :id") + suspend fun deleteWidget(id: Int) +} + +@Database( + entities = [LocationEntity::class, ForecastCacheEntity::class, WidgetConfigEntity::class], + version = 1, + exportSchema = true, +) +abstract class WeatherDb : RoomDatabase() { + abstract fun dao(): WeatherDao + + companion object { + fun create(context: Context) = + Room.databaseBuilder(context, WeatherDb::class.java, "weather.db").build() + } +} diff --git a/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt b/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt new file mode 100644 index 0000000..a610b81 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/DeviceLocation.kt @@ -0,0 +1,50 @@ +package app.wetter.data + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager +import android.os.CancellationSignal +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +/** Platform location without Google Play Services. Coarse is enough for weather. */ +class DeviceLocation(private val context: Context) { + private val manager = context.getSystemService(LocationManager::class.java) + + fun hasPermission() = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + fun isEnabled() = LocationManagerCompat.isLocationEnabled(manager) + + @SuppressLint("MissingPermission") + suspend fun get(): Location? { + if (!hasPermission()) return null + val providers = listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER, LocationManager.PASSIVE_PROVIDER) + .filter { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) } + val lastKnown = providers.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() } + .maxByOrNull { it.time } + if (lastKnown != null && System.currentTimeMillis() - lastKnown.time < FRESH_MS) return lastKnown + val provider = providers.firstOrNull { it != LocationManager.PASSIVE_PROVIDER } ?: return lastKnown + val fresh = withTimeoutOrNull(TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + LocationManagerCompat.getCurrentLocation(manager, provider, signal, ContextCompat.getMainExecutor(context)) { + cont.resume(it) + } + } + } + return fresh ?: lastKnown + } + + private companion object { + const val FRESH_MS = 15 * 60 * 1000L + const val TIMEOUT_MS = 12_000L + } +} diff --git a/wetter/app/src/main/java/app/wetter/data/Format.kt b/wetter/app/src/main/java/app/wetter/data/Format.kt new file mode 100644 index 0000000..6ff39ad --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/Format.kt @@ -0,0 +1,46 @@ +package app.wetter.data + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.time.format.TextStyle +import java.util.Locale +import kotlin.math.roundToInt + +/** All values come metric from the API and are converted here, never in the UI. */ +class Units(val s: Settings) { + fun tempValue(c: Double): Int = (if (s.temp == TempUnit.F) c * 9 / 5 + 32 else c).roundToInt() + fun temp(c: Double): String = "${tempValue(c)}°" + + fun windValue(kmh: Double): Int = when (s.wind) { + WindUnit.KMH -> kmh + WindUnit.MS -> kmh / 3.6 + WindUnit.MPH -> kmh / 1.609 + WindUnit.KN -> kmh / 1.852 + }.roundToInt() + val windUnit get() = when (s.wind) { WindUnit.KMH -> "km/h"; WindUnit.MS -> "m/s"; WindUnit.MPH -> "mph"; WindUnit.KN -> "kn" } + + fun precip(mm: Double): String = if (s.precip == PrecipUnit.IN) String.format(Locale.getDefault(), "%.2f in", mm / 25.4) + else String.format(Locale.getDefault(), "%.1f mm", mm) + + fun pressureValue(hpa: Double): String = if (s.pressure == PressureUnit.INHG) + String.format(Locale.getDefault(), "%.2f", hpa * 0.02953) else String.format(Locale.getDefault(), "%,.0f", hpa) + val pressureUnit get() = if (s.pressure == PressureUnit.INHG) "inHg" else "hPa" + + fun distanceValue(m: Double): String { + val v = if (s.distance == DistanceUnit.MI) m / 1609.0 else m / 1000.0 + return if (v >= 10) v.roundToInt().toString() else String.format(Locale.getDefault(), "%.1f", v) + } + val distanceUnit get() = if (s.distance == DistanceUnit.MI) "mi" else "km" +} + +object Fmt { + private val hour = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) + fun time(t: LocalDateTime): String = t.format(hour) + fun hourLabel(t: LocalDateTime): String = t.format(hour) + fun weekdayShort(t: java.time.LocalDate): String = t.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.') + fun dayMonth(t: java.time.LocalDate): String = DateTimeFormatter.ofPattern("d.M.", Locale.getDefault()).format(t) + fun longDate(t: java.time.LocalDate): String = t.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)) + + fun compass(deg: Int): Int = ((deg % 360 + 360) % 360 + 22) / 45 % 8 +} diff --git a/wetter/app/src/main/java/app/wetter/data/Graph.kt b/wetter/app/src/main/java/app/wetter/data/Graph.kt new file mode 100644 index 0000000..02f6b8d --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/Graph.kt @@ -0,0 +1,18 @@ +package app.wetter.data + +import android.content.Context + +/** Manual dependency container; one instance per process. */ +object Graph { + lateinit var repo: WeatherRepository private set + lateinit var settings: SettingsStore private set + lateinit var device: DeviceLocation private set + + fun init(context: Context) { + if (::repo.isInitialized) return + val app = context.applicationContext + device = DeviceLocation(app) + settings = SettingsStore(app) + repo = WeatherRepository(WeatherDb.create(app).dao(), OpenMeteo(), device) + } +} diff --git a/wetter/app/src/main/java/app/wetter/data/Model.kt b/wetter/app/src/main/java/app/wetter/data/Model.kt new file mode 100644 index 0000000..8a84ee4 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/Model.kt @@ -0,0 +1,108 @@ +package app.wetter.data + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId + +enum class Condition { CLEAR, PARTLY_CLOUDY, CLOUDY, FOG, DRIZZLE, RAIN, SNOW, SLEET, STORM } + +/** WMO weather interpretation codes, as used by Open-Meteo. */ +fun conditionOf(code: Int): Condition = when (code) { + 0, 1 -> Condition.CLEAR + 2 -> Condition.PARTLY_CLOUDY + 3 -> Condition.CLOUDY + 45, 48 -> Condition.FOG + 51, 53, 55 -> Condition.DRIZZLE + 56, 57, 66, 67 -> Condition.SLEET + 61, 63, 65, 80, 81, 82 -> Condition.RAIN + 71, 73, 75, 77, 85, 86 -> Condition.SNOW + 95, 96, 99 -> Condition.STORM + else -> Condition.CLOUDY +} + +/** 0..1, how heavy the precipitation or storm is, for the scene. */ +fun intensityOf(code: Int): Float = when (code) { + 51, 61, 71, 80, 85, 56, 66 -> 0.35f + 53, 63, 73, 81, 95 -> 0.65f + 55, 65, 75, 82, 86, 57, 67, 96, 99 -> 1f + else -> 0.5f +} + +data class Current( + val temp: Double, + val feelsLike: Double, + val code: Int, + val isDay: Boolean, + val humidity: Int, + val dewPoint: Double, + val windSpeed: Double, + val windDir: Int, + val gusts: Double, + val pressure: Double, + val visibility: Double, + val uv: Double, +) + +data class Hour( + val time: LocalDateTime, + val temp: Double, + val code: Int, + val isDay: Boolean, + val precipProb: Int, + val precip: Double, + val windSpeed: Double, + val humidity: Int, +) + +data class Day( + val date: LocalDate, + val code: Int, + val tMin: Double, + val tMax: Double, + val precipProb: Int, + val precipSum: Double, + val sunrise: LocalDateTime, + val sunset: LocalDateTime, + val uvMax: Double, +) + +data class Quarter(val time: LocalDateTime, val precip: Double) + +data class Air( + val aqi: Int?, + val pm25: Double?, + val pm10: Double?, + val pollen: Map, +) + +data class Forecast( + val zone: ZoneId, + val fetchedAt: Long, + val current: Current, + val hourly: List, + val daily: List, + val minutely: List, + val air: Air?, + /** Fixed "now" for previews and screenshot tests. */ + val clock: LocalDateTime? = null, +) { + val now: LocalDateTime get() = clock ?: LocalDateTime.now(zone) + val today: Day? get() = daily.firstOrNull() + + /** Hours from the current one on. */ + fun upcomingHours(count: Int = 24): List { + val start = now.withMinute(0).withSecond(0).withNano(0) + return hourly.filter { !it.time.isBefore(start) }.take(count) + } + + fun hoursOf(date: LocalDate): List = hourly.filter { it.time.toLocalDate() == date } +} + +data class Place( + val name: String, + val admin: String?, + val countryCode: String, + val lat: Double, + val lon: Double, + val timezone: String, +) diff --git a/wetter/app/src/main/java/app/wetter/data/OpenMeteo.kt b/wetter/app/src/main/java/app/wetter/data/OpenMeteo.kt new file mode 100644 index 0000000..4366c6b --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/OpenMeteo.kt @@ -0,0 +1,191 @@ +package app.wetter.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** Public, keyless APIs: Open-Meteo (forecast, air quality, geocoding) and Nominatim (reverse geocoding). */ +class OpenMeteo(private val http: OkHttpClient = defaultClient()) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun forecastJson(lat: Double, lon: Double): String = get( + "https://api.open-meteo.com/v1/forecast".toHttpUrl().newBuilder() + .addQueryParameter("latitude", coord(lat)) + .addQueryParameter("longitude", coord(lon)) + .addQueryParameter("current", CURRENT) + .addQueryParameter("hourly", HOURLY) + .addQueryParameter("daily", DAILY) + .addQueryParameter("minutely_15", "precipitation") + .addQueryParameter("forecast_minutely_15", "16") + .addQueryParameter("timezone", "auto") + .addQueryParameter("forecast_days", "10") + .build().toString() + ) + + suspend fun airJson(lat: Double, lon: Double): String = get( + "https://air-quality-api.open-meteo.com/v1/air-quality".toHttpUrl().newBuilder() + .addQueryParameter("latitude", coord(lat)) + .addQueryParameter("longitude", coord(lon)) + .addQueryParameter("current", AIR) + .addQueryParameter("timezone", "auto") + .build().toString() + ) + + suspend fun search(query: String, language: String = Locale.getDefault().language): List { + val body = get( + "https://geocoding-api.open-meteo.com/v1/search".toHttpUrl().newBuilder() + .addQueryParameter("name", query) + .addQueryParameter("count", "10") + .addQueryParameter("language", language) + .addQueryParameter("format", "json") + .build().toString() + ) + val results = json.parseToJsonElement(body).jsonObject["results"] as? JsonArray ?: return emptyList() + return results.map { it.jsonObject }.map { + Place( + name = it.str("name") ?: "", + admin = it.str("admin1"), + countryCode = it.str("country_code") ?: "", + lat = it.num("latitude") ?: 0.0, + lon = it.num("longitude") ?: 0.0, + timezone = it.str("timezone") ?: "UTC", + ) + } + } + + /** Name for a coordinate. Nominatim allows 1 request/s; we only call it when the position changes. */ + suspend fun reverse(lat: Double, lon: Double, language: String = Locale.getDefault().language): String? { + val body = get( + "https://nominatim.openstreetmap.org/reverse".toHttpUrl().newBuilder() + .addQueryParameter("lat", coord(lat)) + .addQueryParameter("lon", coord(lon)) + .addQueryParameter("format", "jsonv2") + .addQueryParameter("zoom", "10") + .addQueryParameter("accept-language", language) + .build().toString() + ) + val address = json.parseToJsonElement(body).jsonObject["address"]?.jsonObject ?: return null + return listOf("city", "town", "village", "municipality", "county") + .firstNotNullOfOrNull { address.str(it) } + } + + fun parse(forecastJson: String, airJson: String?, fetchedAt: Long): Forecast { + val root = json.parseToJsonElement(forecastJson).jsonObject + val zone = runCatching { ZoneId.of(root.str("timezone")) }.getOrDefault(ZoneId.systemDefault()) + val c = root.getValue("current").jsonObject + val current = Current( + temp = c.num("temperature_2m") ?: 0.0, + feelsLike = c.num("apparent_temperature") ?: 0.0, + code = c.int("weather_code") ?: 3, + isDay = (c.int("is_day") ?: 1) == 1, + humidity = c.int("relative_humidity_2m") ?: 0, + dewPoint = c.num("dew_point_2m") ?: 0.0, + windSpeed = c.num("wind_speed_10m") ?: 0.0, + windDir = c.int("wind_direction_10m") ?: 0, + gusts = c.num("wind_gusts_10m") ?: 0.0, + pressure = c.num("pressure_msl") ?: 0.0, + visibility = c.num("visibility") ?: 0.0, + uv = c.num("uv_index") ?: 0.0, + ) + + val h = root.getValue("hourly").jsonObject + val hTime = h.arr("time") + val hourly = hTime.indices.map { i -> + Hour( + time = LocalDateTime.parse(hTime[i].jsonPrimitive.content), + temp = h.numAt("temperature_2m", i) ?: 0.0, + code = h.intAt("weather_code", i) ?: 3, + isDay = (h.intAt("is_day", i) ?: 1) == 1, + precipProb = h.intAt("precipitation_probability", i) ?: 0, + precip = h.numAt("precipitation", i) ?: 0.0, + windSpeed = h.numAt("wind_speed_10m", i) ?: 0.0, + humidity = h.intAt("relative_humidity_2m", i) ?: 0, + ) + } + + val d = root.getValue("daily").jsonObject + val dTime = d.arr("time") + val daily = dTime.indices.map { i -> + Day( + date = LocalDate.parse(dTime[i].jsonPrimitive.content), + code = d.intAt("weather_code", i) ?: 3, + tMin = d.numAt("temperature_2m_min", i) ?: 0.0, + tMax = d.numAt("temperature_2m_max", i) ?: 0.0, + precipProb = d.intAt("precipitation_probability_max", i) ?: 0, + precipSum = d.numAt("precipitation_sum", i) ?: 0.0, + sunrise = LocalDateTime.parse(d.arr("sunrise")[i].jsonPrimitive.content), + sunset = LocalDateTime.parse(d.arr("sunset")[i].jsonPrimitive.content), + uvMax = d.numAt("uv_index_max", i) ?: 0.0, + ) + } + + val minutely = (root["minutely_15"] as? JsonObject)?.let { m -> + val t = m.arr("time") + t.indices.map { i -> Quarter(LocalDateTime.parse(t[i].jsonPrimitive.content), m.numAt("precipitation", i) ?: 0.0) } + } ?: emptyList() + + val air = airJson?.let { runCatching { parseAir(it) }.getOrNull() } + return Forecast(zone, fetchedAt, current, hourly, daily, minutely, air) + } + + private fun parseAir(body: String): Air { + val c = json.parseToJsonElement(body).jsonObject.getValue("current").jsonObject + val pollen = POLLEN.mapNotNull { key -> c.num("${key}_pollen")?.let { key to it } }.toMap() + return Air(c.int("european_aqi"), c.num("pm2_5"), c.num("pm10"), pollen) + } + + private suspend fun get(url: String): String = withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build() + http.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("HTTP ${response.code}") + response.body?.string() ?: throw IOException("empty body") + } + } + + companion object { + const val USER_AGENT = "Wetter/0.1 (private, non-commercial)" + private const val CURRENT = "temperature_2m,apparent_temperature,weather_code,is_day,relative_humidity_2m," + + "dew_point_2m,wind_speed_10m,wind_direction_10m,wind_gusts_10m,pressure_msl,visibility,uv_index" + private const val HOURLY = "temperature_2m,weather_code,is_day,precipitation_probability,precipitation," + + "wind_speed_10m,relative_humidity_2m" + private const val DAILY = "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max," + + "precipitation_sum,sunrise,sunset,uv_index_max" + val POLLEN = listOf("alder", "birch", "grass", "mugwort", "olive", "ragweed") + private val AIR = "european_aqi,pm2_5,pm10," + POLLEN.joinToString(",") { "${it}_pollen" } + + /** Two decimals (~1 km): enough for weather, and less precise than the real position. */ + private fun coord(v: Double) = String.format(Locale.ROOT, "%.2f", v) + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + } +} + +private fun JsonObject.str(key: String) = (this[key] as? JsonElement)?.takeUnless { it is JsonNull }?.jsonPrimitive?.contentOrNull +private fun JsonObject.num(key: String) = (this[key] as? JsonElement)?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull +private fun JsonObject.int(key: String) = num(key)?.toInt() +private fun JsonObject.arr(key: String): JsonArray = this[key]?.jsonArray ?: JsonArray(emptyList()) +private fun JsonObject.numAt(key: String, i: Int) = arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.doubleOrNull +private fun JsonObject.intAt(key: String, i: Int) = numAt(key, i)?.toInt() ?: arr(key).getOrNull(i)?.takeUnless { it is JsonNull }?.jsonPrimitive?.intOrNull diff --git a/wetter/app/src/main/java/app/wetter/data/Settings.kt b/wetter/app/src/main/java/app/wetter/data/Settings.kt new file mode 100644 index 0000000..6c51c96 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/Settings.kt @@ -0,0 +1,83 @@ +package app.wetter.data + +import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.Locale + +enum class TempUnit { C, F } +enum class WindUnit { KMH, MS, MPH, KN } +enum class PrecipUnit { MM, IN } +enum class PressureUnit { HPA, INHG } +enum class DistanceUnit { KM, MI } +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +data class Settings( + val temp: TempUnit = defaultTemp(), + val wind: WindUnit = WindUnit.KMH, + val precip: PrecipUnit = PrecipUnit.MM, + val pressure: PressureUnit = PressureUnit.HPA, + val distance: DistanceUnit = DistanceUnit.KM, + val theme: ThemeMode = ThemeMode.SYSTEM, + val animations: Boolean = true, + val brief: Boolean = true, +) + +private fun defaultTemp() = + if (Locale.getDefault().country in setOf("US", "LR", "MM")) TempUnit.F else TempUnit.C + +private val Context.store by preferencesDataStore("settings") + +class SettingsStore(private val context: Context) { + private object K { + val temp = stringPreferencesKey("temp") + val wind = stringPreferencesKey("wind") + val precip = stringPreferencesKey("precip") + val pressure = stringPreferencesKey("pressure") + val distance = stringPreferencesKey("distance") + val theme = stringPreferencesKey("theme") + val animations = booleanPreferencesKey("animations") + val brief = booleanPreferencesKey("brief") + } + + val settings: Flow = context.store.data.map { p -> + val d = Settings() + Settings( + temp = p[K.temp].toEnum(d.temp), + wind = p[K.wind].toEnum(d.wind), + precip = p[K.precip].toEnum(d.precip), + pressure = p[K.pressure].toEnum(d.pressure), + distance = p[K.distance].toEnum(d.distance), + theme = p[K.theme].toEnum(d.theme), + animations = p[K.animations] ?: d.animations, + brief = p[K.brief] ?: d.brief, + ) + } + + suspend fun update(transform: (Settings) -> Settings) { + context.store.edit { p -> + val d = Settings() + val current = Settings( + p[K.temp].toEnum(d.temp), p[K.wind].toEnum(d.wind), p[K.precip].toEnum(d.precip), + p[K.pressure].toEnum(d.pressure), p[K.distance].toEnum(d.distance), p[K.theme].toEnum(d.theme), + p[K.animations] ?: d.animations, p[K.brief] ?: d.brief, + ) + val s = transform(current) + p[K.temp] = s.temp.name + p[K.wind] = s.wind.name + p[K.precip] = s.precip.name + p[K.pressure] = s.pressure.name + p[K.distance] = s.distance.name + p[K.theme] = s.theme.name + p[K.animations] = s.animations + p[K.brief] = s.brief + } + } +} + +private inline fun > String?.toEnum(default: E): E = + this?.let { runCatching { enumValueOf(it) }.getOrNull() } ?: default diff --git a/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt new file mode 100644 index 0000000..5a9cc2d --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/data/WeatherRepository.kt @@ -0,0 +1,119 @@ +package app.wetter.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.math.abs + +sealed interface RefreshResult { + data object Ok : RefreshResult + data object Skipped : RefreshResult + data class Failed(val error: Throwable) : RefreshResult +} + +class WeatherRepository( + private val dao: WeatherDao, + private val api: OpenMeteo, + private val device: DeviceLocation, +) { + private val currentLock = Mutex() + + val locations: Flow> = dao.locations() + + fun forecast(locationId: Long): Flow = dao.forecast(locationId).map { cache -> + cache?.let { runCatching { api.parse(it.forecastJson, it.airJson, it.fetchedAt) }.getOrNull() } + } + + suspend fun forecastOnce(locationId: Long): Forecast? = dao.forecastOnce(locationId)?.let { + runCatching { api.parse(it.forecastJson, it.airJson, it.fetchedAt) }.getOrNull() + } + + suspend fun location(id: Long) = dao.location(id) + suspend fun currentLocation() = dao.currentLocation() + suspend fun locationsOnce() = dao.locationsOnce() + + suspend fun refresh(location: LocationEntity, force: Boolean = false): RefreshResult { + val cached = dao.forecastOnce(location.id) + if (!force && cached != null && System.currentTimeMillis() - cached.fetchedAt < STALE_MS) return RefreshResult.Skipped + return runCatching { + val forecast = api.forecastJson(location.lat, location.lon) + val air = runCatching { api.airJson(location.lat, location.lon) }.getOrNull() + dao.putForecast(ForecastCacheEntity(location.id, System.currentTimeMillis(), forecast, air)) + RefreshResult.Ok + }.getOrElse { RefreshResult.Failed(it) } + } + + suspend fun refreshAll(force: Boolean = false) { + updateCurrentLocation() + dao.locationsOnce().forEach { refresh(it, force) } + } + + /** Device position -> the single is_current row. Returns null without permission or fix. */ + suspend fun updateCurrentLocation(): LocationEntity? = currentLock.withLock { + val fix = device.get() ?: return dao.currentLocation() + val existing = dao.currentLocation() + if (existing != null && abs(existing.lat - fix.latitude) < MOVE_DEG && abs(existing.lon - fix.longitude) < MOVE_DEG) { + return existing + } + val name = runCatching { api.reverse(fix.latitude, fix.longitude) }.getOrNull() + val timezone = java.util.TimeZone.getDefault().id + val row = LocationEntity( + id = existing?.id ?: 0, + name = name ?: existing?.name ?: "", + admin = null, + countryCode = "", + lat = fix.latitude, + lon = fix.longitude, + timezone = timezone, + isCurrent = true, + sortOrder = -1, + createdAt = existing?.createdAt ?: System.currentTimeMillis(), + ) + if (existing == null) row.copy(id = dao.insert(row)) else row.also { dao.update(it) } + } + + suspend fun search(query: String): List = api.search(query.trim()) + + /** Full forecast for a place that is not saved yet (S15). */ + suspend fun preview(place: Place): Forecast { + val forecast = api.forecastJson(place.lat, place.lon) + val air = runCatching { api.airJson(place.lat, place.lon) }.getOrNull() + return api.parse(forecast, air, System.currentTimeMillis()) + } + + suspend fun save(place: Place): Long { + val existing = dao.locationsOnce().firstOrNull { + !it.isCurrent && abs(it.lat - place.lat) < DUPE_DEG && abs(it.lon - place.lon) < DUPE_DEG + } + if (existing != null) return existing.id + val id = dao.insert( + LocationEntity( + name = place.name, admin = place.admin, countryCode = place.countryCode, + lat = place.lat, lon = place.lon, timezone = place.timezone, sortOrder = dao.nextSortOrder(), + ) + ) + dao.location(id)?.let { refresh(it, force = true) } + return id + } + + suspend fun delete(id: Long) = dao.delete(id) + suspend fun reorder(ids: List) = dao.reorder(ids) + + suspend fun widgetLocation(appWidgetId: Int): LocationEntity? { + val config = dao.widget(appWidgetId) + return config?.locationId?.let { dao.location(it) } ?: dao.currentLocation() ?: dao.locationsOnce().firstOrNull() + } + + suspend fun setWidgetLocation(appWidgetId: Int, locationId: Long?) = + dao.putWidget(WidgetConfigEntity(appWidgetId, locationId)) + + suspend fun deleteWidget(appWidgetId: Int) = dao.deleteWidget(appWidgetId) + + companion object { + const val STALE_MS = 30 * 60 * 1000L + const val OLD_MS = 3 * 60 * 60 * 1000L + private const val MOVE_DEG = 0.02 + private const val DUPE_DEG = 0.01 + } +} diff --git a/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt b/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt new file mode 100644 index 0000000..4f3161f --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/components/DetailTiles.kt @@ -0,0 +1,347 @@ +package app.wetter.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Air +import androidx.compose.material.icons.outlined.Compress +import androidx.compose.material.icons.outlined.FilterVintage +import androidx.compose.material.icons.outlined.LightMode +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.WaterDrop +import androidx.compose.material.icons.outlined.WbTwilight +import androidx.compose.material.icons.outlined.Masks +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import app.wetter.R +import app.wetter.data.Air +import app.wetter.data.Current +import app.wetter.data.Day +import app.wetter.data.Fmt +import app.wetter.data.OpenMeteo +import app.wetter.data.Units +import app.wetter.ui.theme.LocalWeatherColors +import app.wetter.ui.theme.WColor +import app.wetter.ui.theme.WRadius +import app.wetter.ui.theme.WSpace +import app.wetter.ui.theme.WType +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** Two-column grid of shaped tiles: circles on the left, rounded squares on the right. */ +@Composable +fun DetailGrid(current: Current, day: Day?, air: Air?, units: Units, now: LocalDateTime, modifier: Modifier = Modifier) { + val tiles = buildList<@Composable (Modifier) -> Unit> { + add { WindTile(current, units, it) } + if (day != null) add { SunTile(day, now, it) } + add { UvTile(current.uv, it) } + if (air?.aqi != null) add { AqiTile(air.aqi, it) } + add { VisibilityTile(current.visibility, units, it) } + add { HumidityTile(current.humidity, current.dewPoint, units, it) } + add { PressureTile(current.pressure, units, it) } + if (air != null && air.pollen.isNotEmpty()) add { PollenTile(air.pollen, it) } + } + Column(modifier, verticalArrangement = Arrangement.spacedBy(WSpace.s12)) { + tiles.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s12)) { + row.forEach { tile -> tile(Modifier.weight(1f).aspectRatio(1f)) } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun Tile( + title: String, + icon: ImageVector, + shape: Shape, + description: String, + modifier: Modifier, + centered: Boolean, + background: (androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + val c = LocalWeatherColors.current + Box(modifier.clip(shape).background(c.card).clearAndSetSemantics { contentDescription = description }) { + if (background != null) Canvas(Modifier.fillMaxSize(), onDraw = background) + content() + Row( + Modifier.align(if (centered) Alignment.TopCenter else Alignment.TopStart) + .padding(top = if (centered) 28.dp else WSpace.s16, start = if (centered) 0.dp else WSpace.s16), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = c.onCard, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(WSpace.s4)) + Text(title, style = WType.label, color = c.onCard) + } + } +} + +@Composable +private fun ValueText(value: String, unit: String?, color: Color, modifier: Modifier = Modifier) { + Text( + buildAnnotatedString { + append(value) + if (unit != null) withStyle(SpanStyle(fontSize = WType.title.fontSize)) { append(" $unit") } + }, + style = WType.metric.copy(fontSize = WType.metric.fontSize * 1.25f), color = color, modifier = modifier, + ) +} + +@Composable +fun WindTile(cur: Current, units: Units, modifier: Modifier) { + val c = LocalWeatherColors.current + val res = LocalContext.current.resources + val from = compassLabel(res, cur.windDir) + val value = units.windValue(cur.windSpeed).toString() + val title = stringResource(R.string.card_wind) + Tile(title, Icons.Outlined.Air, CircleShape, "$title $value ${units.windUnit}, ${stringResource(R.string.wind_from, from)}", modifier, centered = true) { + // the blob points where the wind blows to + Box(Modifier.fillMaxSize(.78f).align(Alignment.Center).clip(WShapes.triangle(cur.windDir + 180f + 90f)).background(c.fillSecondary)) + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(WSpace.s12)) + ValueText(value, units.windUnit, c.onFillSecondary) + Text(stringResource(R.string.wind_from, from), style = WType.label, color = c.onFillSecondary) + } + } +} + +@Composable +fun SunTile(day: Day, now: LocalDateTime, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_sun) + val dayLen = ChronoUnit.MINUTES.between(day.sunrise, day.sunset).toFloat() + val progress = (ChronoUnit.MINUTES.between(day.sunrise, now) / dayLen).coerceIn(-0.25f, 1.25f) + val hill = c.fillPrimary + val night = c.onCard.copy(alpha = .08f) + Tile( + title, Icons.Outlined.WbTwilight, RoundedCornerShape(WRadius.card), + "$title, ↑ ${Fmt.time(day.sunrise)}, ↓ ${Fmt.time(day.sunset)}", modifier, centered = false, + background = { + val horizon = size.height * .62f + val amp = size.height * .2f + fun y(x: Float) = horizon + amp * cos((x / size.width) * 2 * PI.toFloat()) + val curve = Path().apply { + moveTo(0f, y(0f)) + var x = 0f + while (x <= size.width) { lineTo(x, y(x)); x += 4f } + } + val fill = Path().apply { addPath(curve); lineTo(size.width, size.height); lineTo(0f, size.height); close() } + clipRect(bottom = horizon) { drawPath(fill, hill) } + drawRect(night, Offset(0f, horizon), Size(size.width, size.height - horizon)) + drawLine(c.onCardMuted.copy(alpha = .5f), Offset(0f, horizon), Offset(size.width, horizon), 2f) + // map day progress 0..1 onto the hump between x=.25w and .75w + val sx = size.width * (.25f + progress * .5f) + drawCircle(WColor.iconSun, size.minDimension * .045f, Offset(sx, y(sx))) + }, + ) { + Column(Modifier.align(Alignment.BottomCenter).padding(bottom = WSpace.s12), horizontalAlignment = Alignment.CenterHorizontally) { + Text("↑ ${Fmt.time(day.sunrise)}", style = WType.label, color = c.onCard) + Text("↓ ${Fmt.time(day.sunset)}", style = WType.label, color = c.onCard) + } + } +} + +@Composable +fun UvTile(uv: Double, modifier: Modifier) { + val c = LocalWeatherColors.current + val level = uvLevel(uv) + val levelText = stringArrayResource(R.array.uv_level)[level] + val title = stringResource(R.string.card_uv) + val value = uv.toInt().toString() + Tile( + title, Icons.Outlined.LightMode, WShapes.cookie12, "$title $value, $levelText", modifier, centered = true, + background = { + // five dots on a lower arc, the active band lit + val r = size.minDimension * .34f + val cx = size.width / 2 + val cy = size.height / 2 + for (i in 0..4) { + val ang = (PI - (PI * .2) - i * (PI * .6 / 4)).toFloat() // 144°..36° below centre + val p = Offset(cx + r * cos(ang), cy + r * sin(ang)) + drawCircle(if (i == level) (if (level >= 2) WColor.uvHigh else WColor.uvDotLow) else WColor.uvDotOff.copy(alpha = .45f), size.minDimension * .04f, p) + } + }, + ) { + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + ValueText(value, null, c.onCard) + Text(levelText, style = WType.label, color = c.onCard) + } + } +} + +@Composable +fun AqiTile(aqi: Int?, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_aqi) + val levelText = aqi?.let { stringArrayResource(R.array.aqi_level)[aqiLevel(it)] } ?: stringResource(R.string.no_data) + val bar = listOf(WColor.aqiBar1, WColor.aqiBar2, WColor.aqiBar3, WColor.aqiBar4, WColor.aqiBar5, WColor.aqiBar6) + val knob = c.onCard + Tile( + title, Icons.Outlined.Masks, RoundedCornerShape(WRadius.card), "$title ${aqi ?: ""}, $levelText", modifier, centered = false, + ) { + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(WSpace.s16), horizontalAlignment = Alignment.End) { + ValueText(aqi?.toString() ?: stringResource(R.string.no_data), null, c.onCard) + Spacer(Modifier.height(WSpace.s12)) + Canvas(Modifier.fillMaxWidth().height(14.dp)) { + val y = size.height / 2 + drawLine(Brush.horizontalGradient(bar), Offset(0f, y), Offset(size.width, y), size.height * .5f, StrokeCap.Round) + if (aqi != null) { + val x = (aqi / 120f).coerceIn(0f, 1f) * size.width + drawCircle(knob, size.height * .55f, Offset(x, y)) + drawCircle(bar[aqiLevel(aqi)], size.height * .38f, Offset(x, y)) + } + } + Spacer(Modifier.height(WSpace.s8)) + Text(levelText, style = WType.label, color = c.onCard) + } + } +} + +@Composable +fun VisibilityTile(meters: Double, units: Units, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_visibility) + val value = units.distanceValue(meters) + val fill = c.fillTertiary + Tile( + title, Icons.Outlined.Visibility, CircleShape, "$title $value ${units.distanceUnit}", modifier, centered = true, + background = { + drawPath(wavyCircle(center, size.minDimension * .40f, 14, size.minDimension * .018f), fill) + }, + ) { + Box(Modifier.align(Alignment.Center).padding(top = WSpace.s16)) { + ValueText(value, units.distanceUnit, c.onFillTertiary) + } + } +} + +@Composable +fun HumidityTile(humidity: Int, dewPoint: Double, units: Units, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_humidity) + val dew = stringResource(R.string.dew_point) + val fill = c.fillTertiary + Tile( + title, Icons.Outlined.WaterDrop, RoundedCornerShape(WRadius.card), + "$title $humidity %, $dew ${units.temp(dewPoint)}", modifier, centered = false, + background = { + val level = size.height * (1f - humidity / 100f * .8f) + val amp = size.height * .025f + val path = Path().apply { + moveTo(0f, level) + var x = 0f + while (x <= size.width) { + lineTo(x, level + amp * sin(x / size.width * 4 * PI.toFloat())) + x += 4f + } + lineTo(size.width, size.height); lineTo(0f, size.height); close() + } + drawPath(path, fill) + }, + ) { + Column(Modifier.align(Alignment.BottomStart).padding(WSpace.s16)) { + ValueText("$humidity", "%", c.onFillTertiary) + Spacer(Modifier.height(WSpace.s8)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(30.dp).clip(CircleShape).background(c.onFillTertiary.copy(alpha = .15f)), contentAlignment = Alignment.Center) { + Text(units.temp(dewPoint), style = WType.labelSm, color = c.onFillTertiary) + } + Spacer(Modifier.width(WSpace.s8)) + Text(dew, style = WType.label, color = c.onFillTertiary) + } + } + } +} + +@Composable +fun PressureTile(hpa: Double, units: Units, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_pressure) + val value = units.pressureValue(hpa) + val track = c.onCard.copy(alpha = .15f) + val active = c.fillPrimary + Tile( + title, Icons.Outlined.Compress, CircleShape, "$title $value ${units.pressureUnit}", modifier, centered = true, + background = { + val stroke = size.minDimension * .045f + val inset = stroke * 1.4f + val arcSize = Size(size.width - inset * 2, size.height - inset * 2) + val frac = ((hpa - 960) / 90).toFloat().coerceIn(0f, 1f) + drawArc(track, 135f, 270f, false, Offset(inset, inset), arcSize, style = Stroke(stroke, cap = StrokeCap.Round)) + drawArc(active, 135f, 270f * frac, false, Offset(inset, inset), arcSize, style = Stroke(stroke, cap = StrokeCap.Round)) + }, + ) { + Column(Modifier.align(Alignment.Center).padding(top = WSpace.s16), horizontalAlignment = Alignment.CenterHorizontally) { + ValueText(value, null, c.onCard) + Text(units.pressureUnit, style = WType.label, color = c.onCard) + } + } +} + +@Composable +fun PollenTile(pollen: Map, modifier: Modifier) { + val c = LocalWeatherColors.current + val title = stringResource(R.string.card_pollen) + val names = stringArrayResource(R.array.pollen_names) + val top = pollen.entries.filter { it.value >= 1 }.sortedByDescending { it.value }.take(3) + val label = { key: String -> names.getOrElse(OpenMeteo.POLLEN.indexOf(key)) { key } } + val desc = if (top.isEmpty()) stringResource(R.string.pollen_none) else top.joinToString { label(it.key) } + Tile(title, Icons.Outlined.FilterVintage, RoundedCornerShape(WRadius.card), "$title: $desc", modifier, centered = false) { + Column(Modifier.align(Alignment.BottomStart).padding(WSpace.s16), verticalArrangement = Arrangement.spacedBy(WSpace.s8)) { + if (top.isEmpty()) Text(stringResource(R.string.pollen_none), style = WType.bodySm, color = c.onCard) + top.forEach { (key, v) -> + // grains/m³: <10 low, <50 medium, else high + val level = when { v < 10 -> 1; v < 50 -> 2; else -> 3 } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label(key), style = WType.bodySm, color = c.onCard, modifier = Modifier.width(72.dp)) + repeat(3) { i -> + Box(Modifier.padding(end = 4.dp).size(10.dp).clip(CircleShape).background(if (i < level) c.fillPrimary else c.onCard.copy(alpha = .15f))) + } + } + } + } + } +} diff --git a/wetter/app/src/main/java/app/wetter/ui/components/Labels.kt b/wetter/app/src/main/java/app/wetter/ui/components/Labels.kt new file mode 100644 index 0000000..da07e56 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/components/Labels.kt @@ -0,0 +1,80 @@ +package app.wetter.ui.components + +import android.content.res.Resources +import androidx.annotation.StringRes +import app.wetter.R +import app.wetter.data.Condition +import app.wetter.data.Forecast +import app.wetter.data.Fmt +import app.wetter.data.Units +import kotlin.math.roundToInt + +@StringRes +fun conditionLabel(condition: Condition, isDay: Boolean): Int = when (condition) { + Condition.CLEAR -> if (isDay) R.string.cond_clear_day else R.string.cond_clear_night + Condition.PARTLY_CLOUDY -> R.string.cond_partly + Condition.CLOUDY -> R.string.cond_cloudy + Condition.FOG -> R.string.cond_fog + Condition.DRIZZLE -> R.string.cond_drizzle + Condition.RAIN -> R.string.cond_rain + Condition.SNOW -> R.string.cond_snow + Condition.SLEET -> R.string.cond_sleet + Condition.STORM -> R.string.cond_storm +} + +/** Rule-based summary in place of an AI report: what happens next, and how tomorrow compares. */ +fun buildBrief(res: Resources, f: Forecast, units: Units): String { + val parts = mutableListOf() + val now = f.now + val soon = f.minutely.filter { !it.time.isBefore(now.minusMinutes(15)) }.take(12) + if (soon.firstOrNull()?.let { it.precip >= 0.1 } == true || isWet(app.wetter.data.conditionOf(f.current.code))) { + val dryAt = soon.firstOrNull { it.precip < 0.1 }?.time + parts += if (dryAt != null) res.getString(R.string.brief_wet_now_until, Fmt.time(dryAt)) else res.getString(R.string.brief_wet_now) + } + val hours = f.upcomingHours(18) + val wet = if (parts.isNotEmpty()) null else hours.drop(1).firstOrNull { it.precipProb >= 50 && it.precip >= 0.2 } + if (parts.isNotEmpty()) { + // already said + } else if (wet != null) { + val c = app.wetter.data.conditionOf(wet.code) + val at = wet.time.hour.toString() + parts += when (c) { + Condition.SNOW, Condition.SLEET -> res.getString(R.string.brief_snow_from, at) + Condition.STORM -> res.getString(R.string.brief_storm_from, at) + else -> res.getString(R.string.brief_rain_from, at) + } + } else { + parts += res.getString(if (f.now.hour < 15) R.string.brief_dry else R.string.brief_dry_rest) + } + val today = f.daily.getOrNull(0) + val tomorrow = f.daily.getOrNull(1) + if (today != null && tomorrow != null) { + val diff = units.tempValue(tomorrow.tMax) - units.tempValue(today.tMax) + parts += when { + diff >= 3 -> res.getString(R.string.brief_warmer, diff) + diff <= -3 -> res.getString(R.string.brief_colder, -diff) + else -> res.getString(R.string.brief_same) + } + } + if (f.current.gusts >= 50) parts += res.getString(R.string.brief_wind, "${units.windValue(f.current.gusts)} ${units.windUnit}") + val uvHours = f.hoursOf(f.now.toLocalDate()) + if ((today?.uvMax ?: 0.0) >= 6 && f.current.isDay && uvHours.isNotEmpty()) { + parts += res.getString(R.string.brief_uv, "11", "16") + } + return parts.joinToString(" ") +} + +fun uvLevel(uv: Double): Int = when { + uv < 3 -> 0 + uv < 6 -> 1 + uv < 8 -> 2 + uv < 11 -> 3 + else -> 4 +} + +/** European AQI bands: 0-20, 20-40, 40-60, 60-80, 80-100, >100. */ +fun aqiLevel(aqi: Int): Int = (aqi / 20).coerceIn(0, 5) + +fun minutesAgo(fetchedAt: Long): Int = ((System.currentTimeMillis() - fetchedAt) / 60_000.0).roundToInt() + +fun compassLabel(res: Resources, deg: Int): String = res.getStringArray(R.array.compass)[Fmt.compass(deg)] diff --git a/wetter/app/src/main/java/app/wetter/ui/components/Sections.kt b/wetter/app/src/main/java/app/wetter/ui/components/Sections.kt new file mode 100644 index 0000000..b626aa4 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/components/Sections.kt @@ -0,0 +1,312 @@ +package app.wetter.ui.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Notes +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Umbrella +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.wetter.R +import app.wetter.data.Condition +import app.wetter.data.Day +import app.wetter.data.Fmt +import app.wetter.data.Forecast +import app.wetter.data.Hour +import app.wetter.data.Quarter +import app.wetter.data.Units +import app.wetter.data.conditionOf +import app.wetter.ui.scene.WeatherIcon +import app.wetter.ui.theme.LocalWeatherColors +import app.wetter.ui.theme.WRadius +import app.wetter.ui.theme.WSpace +import app.wetter.ui.theme.WType +import java.time.LocalDate + +@Composable +fun SectionCard( + title: String, + icon: ImageVector, + modifier: Modifier = Modifier, + trailing: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val c = LocalWeatherColors.current + Column( + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(WRadius.card)) + .background(c.card) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = WSpace.s20) + .animateContentSize() + ) { + Row(Modifier.padding(horizontal = WSpace.s20), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = c.onCard, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(WSpace.s12)) + Text(title, style = WType.cardTitle, color = c.onCard, modifier = Modifier.weight(1f).semantics { heading() }) + trailing?.invoke() + } + Spacer(Modifier.height(WSpace.s16)) + content() + } +} + +@Composable +fun HeroBlock( + place: String, + forecast: Forecast, + units: Units, + textColor: Color, + modifier: Modifier = Modifier, + dayOverride: Day? = null, +) { + val cur = forecast.current + val condition = conditionOf(cur.code) + val today = forecast.today + val conditionText = stringResource(conditionLabel(condition, cur.isDay)) + val feels = stringResource(R.string.feels_like, units.temp(cur.feelsLike)) + val hl = today?.let { stringResource(R.string.high_low, units.temp(it.tMax), units.temp(it.tMin)) } ?: "" + val summary = "$place, ${units.temp(cur.temp)}, $conditionText, $feels, $hl" + Column( + modifier.fillMaxWidth().padding(vertical = WSpace.s24).clearAndSetSemantics { contentDescription = summary }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(conditionText, style = WType.heroCondition, color = textColor) + Spacer(Modifier.height(WSpace.s8)) + Row(verticalAlignment = Alignment.Top) { + Text( + "${units.tempValue(cur.temp)}", + style = WType.display.copy(letterSpacing = (-4).sp()), + color = textColor, + ) + WeatherIcon(condition, cur.isDay, 64.dp, Modifier.padding(top = WSpace.s12)) + } + Spacer(Modifier.height(WSpace.s8)) + Text(feels, style = WType.title, color = textColor) + Spacer(Modifier.height(WSpace.s4)) + Text(hl, style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = textColor) + } +} + +private fun Int.sp() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp) + +@Composable +fun HourlyStrip(hours: List, units: Units, modifier: Modifier = Modifier, markNow: Boolean = true) { + val c = LocalWeatherColors.current + SectionCard(stringResource(R.string.hourly), Icons.Outlined.Schedule, modifier) { + LazyRow( + contentPadding = PaddingValues(horizontal = WSpace.s16), + horizontalArrangement = Arrangement.spacedBy(WSpace.s4), + ) { + itemsIndexed(hours) { i, h -> + val isNow = markNow && i == 0 + val cond = conditionOf(h.code) + val label = if (isNow) stringResource(R.string.now) else Fmt.hourLabel(h.time) + val condText = stringResource(conditionLabel(cond, h.isDay)) + Column( + Modifier.widthIn(min = 52.dp).semantics(mergeDescendants = true) { + contentDescription = "$label, ${units.temp(h.temp)}, $condText, ${h.precipProb} %" + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + if (isNow) Box(Modifier.size(38.dp).clip(WShapes.cookie8).background(c.nowChip)) + Text(units.temp(h.temp), style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = if (isNow) c.onNowChip else c.onCard) + } + Spacer(Modifier.height(WSpace.s8)) + WeatherIcon(cond, h.isDay, 28.dp) + Spacer(Modifier.height(WSpace.s4)) + Text( + if (h.precipProb >= 10) "${h.precipProb} %" else " ", + style = WType.labelSm, color = c.precipText, + ) + Spacer(Modifier.height(WSpace.s4)) + Text(label, style = WType.label, color = c.onCardMuted) + } + } + } + } +} + +@Composable +fun DailyCapsules( + days: List, + units: Units, + modifier: Modifier = Modifier, + selected: LocalDate? = null, + asSection: Boolean = true, + onDay: (Day) -> Unit, +) { + val c = LocalWeatherColors.current + val today = days.firstOrNull()?.date + val row = @Composable { + LazyRow( + contentPadding = PaddingValues(horizontal = WSpace.s16), + horizontalArrangement = Arrangement.spacedBy(WSpace.s8), + ) { + itemsIndexed(days) { _, d -> + val isSel = d.date == selected + val cond = conditionOf(d.code) + val name = if (d.date == today) stringResource(R.string.today) else Fmt.weekdayShort(d.date) + val condText = stringResource(conditionLabel(cond, true)) + val fg = if (isSel) c.onPillSelected else c.onCard + Column( + Modifier + .width(60.dp) + .clip(RoundedCornerShape(WRadius.dayPill * 2)) + .background(if (isSel) c.pillSelected else c.pill) + .clickable { onDay(d) } + .padding(vertical = WSpace.s16) + .semantics(mergeDescendants = true) { + contentDescription = "$name, $condText, ${units.temp(d.tMin)} bis ${units.temp(d.tMax)}, ${d.precipProb} %" + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(units.temp(d.tMax), style = WType.bodySm.copy(fontWeight = FontWeight.Medium), color = fg) + Text(units.temp(d.tMin), style = WType.bodySm, color = if (isSel) fg else c.onCardMuted) + Spacer(Modifier.height(WSpace.s16)) + WeatherIcon(cond, true, 34.dp) + Spacer(Modifier.height(WSpace.s8)) + Text(if (d.precipProb >= 10) "${d.precipProb} %" else " ", style = WType.labelSm, color = if (isSel) fg else c.precipText) + Spacer(Modifier.height(WSpace.s12)) + Text(name, style = WType.label, color = fg, textAlign = TextAlign.Center) + Text(Fmt.dayMonth(d.date), style = WType.labelSm, color = if (isSel) fg else c.onCardMuted) + } + } + } + } + if (asSection) SectionCard(stringResource(R.string.daily), Icons.Outlined.CalendarMonth, modifier) { row() } + else Box(modifier) { row() } +} + +@Composable +fun BriefCard(text: String, modifier: Modifier = Modifier) { + val c = LocalWeatherColors.current + var open by rememberSaveable { mutableStateOf(true) } + SectionCard( + stringResource(R.string.brief_title), Icons.Outlined.Notes, modifier, + trailing = { Icon(if (open) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, tint = c.onCard) }, + onClick = { open = !open }, + ) { + if (open) Text(text, style = WType.body, color = c.onCard, modifier = Modifier.padding(horizontal = WSpace.s20)) + } +} + +/** Shows only when precipitation falls within the next ~3 hours (15-minute steps). */ +@Composable +fun NowcastCard(quarters: List, forecast: Forecast, modifier: Modifier = Modifier) { + val c = LocalWeatherColors.current + val now = forecast.now + val upcoming = quarters.filter { !it.time.isBefore(now.minusMinutes(15)) }.take(12) + val firstWet = upcoming.indexOfFirst { it.precip >= 0.1 } + if (firstWet < 0) return + val lastWet = upcoming.indexOfLast { it.precip >= 0.1 } + val peak = upcoming.maxOf { it.precip } + val levelText = stringResource( + when { + peak >= 2.5 -> R.string.precip_heavy + peak >= 0.6 -> R.string.precip_moderate + else -> R.string.precip_light + } + ) + val start = Fmt.time(upcoming[firstWet].time) + val end = Fmt.time(upcoming[lastWet].time.plusMinutes(15)) + val title = if (firstWet == 0) stringResource(R.string.nowcast_title_ongoing, end) else stringResource(R.string.nowcast_title_rain, start) + val primary = MaterialTheme.colorScheme.primary + SectionCard(title, Icons.Outlined.Umbrella, modifier) { + Column(Modifier.padding(horizontal = WSpace.s20)) { + Text(stringResource(R.string.nowcast_sub, levelText, start, end), style = WType.bodySm, color = c.onCardMuted) + Spacer(Modifier.height(WSpace.s16)) + Row { + Column(Modifier.height(96.dp), verticalArrangement = Arrangement.SpaceBetween) { + listOf(R.string.nowcast_heavy, R.string.nowcast_moderate, R.string.nowcast_light).forEach { + Text(stringResource(it), style = WType.labelSm, color = c.onCardMuted) + } + } + Spacer(Modifier.width(WSpace.s12)) + Canvas(Modifier.weight(1f).height(96.dp)) { + val n = upcoming.size + val step = size.width / n + val dot = (step * .62f).coerceAtMost(size.height / 6) + upcoming.forEachIndexed { i, q -> + val level = when { + q.precip >= 2.5 -> 3 + q.precip >= 0.6 -> 2 + q.precip >= 0.1 -> 1 + else -> 0 + } + val x = step * i + step / 2 + if (level == 0) { + drawCircle(c.onCardMuted.copy(alpha = .25f), dot * .18f, Offset(x, size.height - dot / 2)) + } else { + val top = size.height - size.height * level / 3f + dot / 2 + drawLine(primary, Offset(x, size.height - dot / 2), Offset(x, top), dot, androidx.compose.ui.graphics.StrokeCap.Round) + } + } + } + } + Spacer(Modifier.height(WSpace.s8)) + Row(Modifier.fillMaxWidth().padding(start = 56.dp), horizontalArrangement = Arrangement.SpaceBetween) { + listOf(0, upcoming.size / 2, upcoming.size - 1).forEach { + Text(Fmt.time(upcoming[it].time), style = WType.labelSm, color = c.onCardMuted) + } + } + } + } +} + +@Composable +fun StatusChip(text: String, color: Color, modifier: Modifier = Modifier) { + Text( + text, style = WType.label, color = color, + modifier = modifier + .clip(RoundedCornerShape(WRadius.pill)) + .background(color.copy(alpha = .14f)) + .padding(horizontal = WSpace.s12, vertical = WSpace.s4), + ) +} + +internal fun isWet(c: Condition) = c in setOf(Condition.RAIN, Condition.DRIZZLE, Condition.SNOW, Condition.SLEET, Condition.STORM) diff --git a/wetter/app/src/main/java/app/wetter/ui/components/Shapes.kt b/wetter/app/src/main/java/app/wetter/ui/components/Shapes.kt new file mode 100644 index 0000000..b60dfa8 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/components/Shapes.kt @@ -0,0 +1,55 @@ +package app.wetter.ui.components + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.graphics.shapes.CornerRounding +import androidx.graphics.shapes.RoundedPolygon +import androidx.graphics.shapes.star +import androidx.graphics.shapes.toPath +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** Any RoundedPolygon as a Compose Shape, scaled to the layout size. */ +class PolygonShape(polygon: RoundedPolygon, private val rotation: Float = 0f) : Shape { + private val normalized = polygon.normalized() + + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val path = normalized.toPath().asComposePath() + val m = Matrix() + if (rotation != 0f) { + m.translate(size.width / 2, size.height / 2) + m.rotateZ(rotation) + m.translate(-size.width / 2, -size.height / 2) + } + m.scale(size.width, size.height) + path.transform(m) + return Outline.Generic(path) + } +} + +object WShapes { + val cookie12 = PolygonShape(RoundedPolygon.star(12, innerRadius = .86f, rounding = CornerRounding(.18f))) + val cookie8 = PolygonShape(RoundedPolygon.star(8, innerRadius = .80f, rounding = CornerRounding(.25f))) + fun triangle(rotation: Float) = PolygonShape(RoundedPolygon(3, rounding = CornerRounding(.32f)), rotation) +} + +/** Circle whose radius is modulated by a sine: the "wavy" M3 Expressive edge. */ +fun wavyCircle(center: Offset, radius: Float, waves: Int, amplitude: Float): Path = Path().apply { + val steps = 180 + for (i in 0..steps) { + val a = 2 * PI * i / steps + val r = radius + amplitude * sin(waves * a).toFloat() + val x = center.x + r * cos(a).toFloat() + val y = center.y + r * sin(a).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} diff --git a/wetter/app/src/main/java/app/wetter/ui/scene/WeatherIcon.kt b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherIcon.kt new file mode 100644 index 0000000..bc7a582 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherIcon.kt @@ -0,0 +1,148 @@ +package app.wetter.ui.scene + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import app.wetter.data.Condition +import app.wetter.ui.theme.WColor +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** Own weather glyphs, drawn from primitives so they scale and render in widgets too. */ +@Composable +fun WeatherIcon(condition: Condition, isDay: Boolean, size: Dp, modifier: Modifier = Modifier, description: String? = null) { + Canvas( + modifier + .size(size) + .then(if (description != null) Modifier.semantics { contentDescription = description } else Modifier) + ) { drawWeatherIcon(condition, isDay) } +} + +fun DrawScope.drawWeatherIcon(condition: Condition, isDay: Boolean) { + val s = size.minDimension + when (condition) { + Condition.CLEAR -> if (isDay) sun(Offset(s * .5f, s * .5f), s * .30f) else moon(Offset(s * .5f, s * .5f), s * .32f) + Condition.PARTLY_CLOUDY -> { + if (isDay) sun(Offset(s * .62f, s * .36f), s * .22f) else moon(Offset(s * .62f, s * .34f), s * .22f) + cloud(Offset(s * .44f, s * .62f), s * .62f) + } + Condition.CLOUDY -> { + cloud(Offset(s * .60f, s * .42f), s * .46f, WColor.iconCloudShade) + cloud(Offset(s * .44f, s * .60f), s * .66f) + } + Condition.FOG -> { + cloud(Offset(s * .5f, s * .42f), s * .62f, WColor.iconCloudShade) + for (i in 0..2) { + val y = s * (.66f + i * .11f) + drawLine(WColor.iconFog, Offset(s * (.18f + i * .06f), y), Offset(s * (.82f - i * .04f), y), s * .06f, StrokeCap.Round) + } + } + Condition.DRIZZLE, Condition.RAIN -> { + cloud(Offset(s * .5f, s * .40f), s * .70f, if (condition == Condition.RAIN) WColor.iconCloudDark else WColor.iconCloudShade) + val n = if (condition == Condition.RAIN) 3 else 2 + for (i in 0 until n) drop(Offset(s * (.32f + i * (.36f / (n - 1).coerceAtLeast(1))), s * (.74f + (i % 2) * .08f)), s * .11f) + } + Condition.SNOW -> { + cloud(Offset(s * .5f, s * .40f), s * .70f, WColor.iconCloudShade) + for (i in 0..2) flake(Offset(s * (.30f + i * .2f), s * (.76f + (i % 2) * .08f)), s * .08f) + } + Condition.SLEET -> { + cloud(Offset(s * .5f, s * .40f), s * .70f, WColor.iconCloudShade) + drop(Offset(s * .34f, s * .76f), s * .1f) + flake(Offset(s * .62f, s * .80f), s * .08f) + } + Condition.STORM -> { + cloud(Offset(s * .5f, s * .38f), s * .72f, WColor.iconCloudDark) + bolt(Offset(s * .52f, s * .58f), s * .34f) + } + } +} + +private fun DrawScope.sun(c: Offset, r: Float) { + // a soft 12-bump "cookie" sun, filled, no rays + val path = Path() + val bumps = 12 + val steps = 96 + for (i in 0..steps) { + val a = 2 * PI * i / steps + val rr = r * (1f + 0.07f * cos(bumps * a).toFloat()) + val p = Offset(c.x + rr * cos(a).toFloat(), c.y + rr * sin(a).toFloat()) + if (i == 0) path.moveTo(p.x, p.y) else path.lineTo(p.x, p.y) + } + path.close() + drawPath(path, WColor.iconSun) + drawCircle(WColor.iconSunCore, r * .62f, c, alpha = .35f) +} + +private fun DrawScope.moon(c: Offset, r: Float) { + val path = Path().apply { + addOval(androidx.compose.ui.geometry.Rect(c, r)) + } + val cut = Path().apply { + addOval(androidx.compose.ui.geometry.Rect(Offset(c.x + r * .55f, c.y - r * .35f), r * .9f)) + } + val crescent = Path().apply { op(path, cut, androidx.compose.ui.graphics.PathOperation.Difference) } + drawPath(crescent, WColor.iconMoon) +} + +/** Cloud from three circles and a rounded base; width w centred on c. */ +private fun DrawScope.cloud(c: Offset, w: Float, color: Color = WColor.iconCloud) { + val h = w * .5f + translate(c.x - w / 2, c.y - h / 2) { + // soft underside so white clouds still read on light cards + val shade = WColor.iconCloudShade + drawCircle(shade, h * .42f, Offset(w * .28f, h * .70f)) + drawCircle(shade, h * .38f, Offset(w * .78f, h * .74f)) + drawRoundRect(shade, Offset(w * .1f, h * .66f), Size(w * .8f, h * .42f), androidx.compose.ui.geometry.CornerRadius(h * .21f)) + drawCircle(color, h * .42f, Offset(w * .28f, h * .62f)) + drawCircle(color, h * .56f, Offset(w * .54f, h * .44f)) + drawCircle(color, h * .38f, Offset(w * .78f, h * .66f)) + drawRoundRect(color, Offset(w * .1f, h * .58f), Size(w * .8f, h * .42f), androidx.compose.ui.geometry.CornerRadius(h * .21f)) + } +} + +private fun DrawScope.drop(c: Offset, r: Float) { + val path = Path().apply { + moveTo(c.x, c.y - r * 1.6f) + cubicTo(c.x + r * .2f, c.y - r * .8f, c.x + r, c.y - r * .2f, c.x + r, c.y + r * .2f) + cubicTo(c.x + r, c.y + r * .8f, c.x + r * .5f, c.y + r * 1.1f, c.x, c.y + r * 1.1f) + cubicTo(c.x - r * .5f, c.y + r * 1.1f, c.x - r, c.y + r * .8f, c.x - r, c.y + r * .2f) + cubicTo(c.x - r, c.y - r * .2f, c.x - r * .2f, c.y - r * .8f, c.x, c.y - r * 1.6f) + close() + } + drawPath(path, WColor.iconDrop) +} + +private fun DrawScope.flake(c: Offset, r: Float) { + drawCircle(WColor.iconSnowOutline, r * 1.15f, c) + drawCircle(WColor.iconSnow, r, c) +} + +private fun DrawScope.bolt(c: Offset, h: Float) { + val w = h * .55f + val path = Path().apply { + moveTo(c.x + w * .15f, c.y) + lineTo(c.x - w * .45f, c.y + h * .55f) + lineTo(c.x - w * .02f, c.y + h * .55f) + lineTo(c.x - w * .2f, c.y + h) + lineTo(c.x + w * .45f, c.y + h * .38f) + lineTo(c.x + w * .02f, c.y + h * .38f) + lineTo(c.x + w * .25f, c.y) + close() + } + drawPath(path, WColor.iconBolt) + drawPath(path, WColor.iconSunCore, style = Stroke(width = h * .03f)) +} diff --git a/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt new file mode 100644 index 0000000..70b68d5 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/scene/WeatherScene.kt @@ -0,0 +1,175 @@ +package app.wetter.ui.scene + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.animateFloat +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import app.wetter.data.Condition +import app.wetter.ui.theme.WColor +import kotlin.math.PI +import kotlin.math.sin +import kotlin.random.Random + +@Immutable +data class SceneSpec(val condition: Condition, val isDay: Boolean, val intensity: Float = .5f) { + val top: Color get() = palette().first + val bottom: Color get() = palette().second + + /** Light skies get dark text (on-scene-dark), dark skies white (on-scene). */ + val isLight: Boolean get() = isDay && condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY, Condition.CLOUDY, Condition.FOG, Condition.SNOW) + val onScene: Color get() = if (isLight) WColor.onSceneDark else WColor.onScene + + private fun palette(): Pair = when { + condition == Condition.STORM -> WColor.sceneStormTop to WColor.sceneStormBottom + condition == Condition.RAIN || condition == Condition.DRIZZLE || condition == Condition.SLEET -> + if (isDay) WColor.sceneRainTop to WColor.sceneRainBottom else WColor.sceneClearNightTop to WColor.sceneRainTop + !isDay -> WColor.sceneClearNightTop to WColor.sceneClearNightBottom + condition == Condition.SNOW -> WColor.sceneSnowTop to WColor.sceneSnowBottom + condition == Condition.FOG -> WColor.sceneFogTop to WColor.sceneFogBottom + condition == Condition.CLOUDY -> WColor.sceneCloudyDayTop to WColor.sceneCloudyDayBottom + else -> WColor.sceneClearDayTop to WColor.sceneClearDayBottom + } +} + +/** + * Animated sky. Soft gradient, slow drifting light shapes, and precipitation particles. + * [animate] false draws one still frame (battery saver, reduced motion, setting off). + */ +@Composable +fun WeatherScene(spec: SceneSpec, modifier: Modifier = Modifier, animate: Boolean = true, scroll: Float = 0f) { + val top by animateColorAsState(spec.top, tween(600), label = "top") + val bottom by animateColorAsState(spec.bottom, tween(600), label = "bottom") + val t = if (animate) { + val transition = rememberInfiniteTransition(label = "scene") + val v by transition.animateFloat(0f, 1f, infiniteRepeatable(tween(60_000, easing = LinearEasing)), label = "t") + v + } else 0.2f + val particles = remember(spec.condition) { Particles.make(spec.condition) } + + Canvas(modifier.fillMaxSize()) { + drawRect(Brush.verticalGradient(listOf(top, bottom))) + drawSceneLayers(spec, t, scroll, particles) + } +} + +fun DrawScope.drawSceneLayers(spec: SceneSpec, t: Float, scroll: Float, particles: Particles) { + val w = size.width + val h = size.height + val parallax = -scroll * .3f + + // Sun glow or stars + if (spec.isDay && spec.condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY)) { + val c = Offset(w * .82f, h * .08f + parallax) + drawCircle(Brush.radialGradient(listOf(WColor.sceneGlowSun.copy(alpha = .75f), Color.Transparent), c, w * .7f), w * .7f, c) + } + if (!spec.isDay && spec.condition in setOf(Condition.CLEAR, Condition.PARTLY_CLOUDY)) { + particles.stars.forEach { s -> + val twinkle = .5f + .5f * sin((t * 2 * PI * 20 + s.phase).toFloat()) + drawCircle(Color.White.copy(alpha = .25f + .55f * twinkle), s.r, Offset(s.x * w, s.y * h * .55f + parallax)) + } + } + + // Big soft hills at the bottom (the calm, illustrative look) + val hill = if (spec.isLight) WColor.sceneHill else WColor.darkSceneHill + drawHill(hill.copy(alpha = .35f), h * .78f + parallax * .5f, h * .07f, w * .9f, t * 2 * PI.toFloat()) + drawHill(hill.copy(alpha = .5f), h * .86f + parallax * .5f, h * .05f, w * 1.3f, t * 2 * PI.toFloat() + 1.7f) + + // Drifting cloud shapes + val cloudAlpha = when (spec.condition) { + Condition.CLEAR -> .10f + Condition.PARTLY_CLOUDY -> .22f + Condition.CLOUDY, Condition.FOG, Condition.SNOW -> .32f + else -> .18f + } + particles.clouds.forEach { c -> + val x = ((c.x + t * c.speed) % 1.4f - .2f) * w + val y = c.y * h * .6f + parallax * .7f + val r = c.r * w + drawCircle(Brush.radialGradient(listOf(WColor.sceneCloud.copy(alpha = cloudAlpha), Color.Transparent), Offset(x, y), r), r, Offset(x, y)) + } + + // Precipitation + val count = (particles.drops.size * spec.intensity.coerceIn(.25f, 1f)).toInt() + when (spec.condition) { + Condition.RAIN, Condition.DRIZZLE, Condition.STORM, Condition.SLEET -> { + val len = if (spec.condition == Condition.DRIZZLE) h * .012f else h * .03f + particles.drops.take(count).forEach { d -> + val y = ((d.y + t * 90f * d.speed) % 1f) * h + val x = (d.x * w + y * .12f) % w + drawLine(Color.White.copy(alpha = .35f), Offset(x, y), Offset(x + len * .12f, y + len), 2f, StrokeCap.Round) + } + } + Condition.SNOW -> particles.drops.take(count).forEach { d -> + val y = ((d.y + t * 18f * d.speed) % 1f) * h + val x = d.x * w + sin((t * 2 * PI * 30 + d.phase).toFloat()) * 18f + drawCircle(Color.White.copy(alpha = .8f), 2.5f + d.speed * 2.5f, Offset(x, y)) + } + Condition.FOG -> for (i in 0..3) { + val y = h * (.25f + i * .17f) + parallax * .4f + val dx = sin((t * 2 * PI + i).toFloat()) * w * .08f + drawRect( + Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = .22f), Color.Transparent), startX = dx, endX = w + dx), + topLeft = Offset(0f, y), size = androidx.compose.ui.geometry.Size(w, h * .07f), + ) + } + else -> Unit + } + + // Lightning: two short flashes per minute + if (spec.condition == Condition.STORM) { + val phase = (t * 2f) % 1f + if (phase in 0.40f..0.415f || phase in 0.43f..0.44f) drawRect(Color.White.copy(alpha = .35f)) + } +} + +private fun DrawScope.drawHill(color: Color, baseY: Float, amp: Float, wavelength: Float, phase: Float) { + val w = size.width + val path = Path().apply { + moveTo(0f, size.height) + var x = 0f + while (x <= w + 8f) { + lineTo(x, baseY - amp * sin(x / wavelength * 2 * PI.toFloat() + phase * .2f)) + x += 8f + } + lineTo(w, size.height) + close() + } + drawPath(path, color) +} + +class Particles(val stars: List

, val clouds: List

, val drops: List

) { + data class P(val x: Float, val y: Float, val r: Float, val speed: Float, val phase: Float) + + companion object { + fun make(condition: Condition, seed: Int = 7): Particles { + val rnd = Random(seed) + fun p(r: ClosedFloatingPointRange) = P(rnd.nextFloat(), rnd.nextFloat(), r.start + rnd.nextFloat() * (r.endInclusive - r.start), .5f + rnd.nextFloat(), rnd.nextFloat() * 6.28f) + val dropCount = when (condition) { + Condition.RAIN, Condition.STORM -> 140 + Condition.DRIZZLE, Condition.SLEET -> 80 + Condition.SNOW -> 90 + else -> 0 + } + return Particles( + stars = List(60) { p(1f..2.6f) }, + clouds = List(6) { p(0.35f..0.6f) }, + drops = List(dropCount) { p(1f..1f) }, + ) + } + } +} diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt new file mode 100644 index 0000000..685d039 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/theme/Theme.kt @@ -0,0 +1,102 @@ +package app.wetter.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import app.wetter.data.ThemeMode + +/** Roles the M3 scheme has no slot for: cards on the scene, tonal fills, day pills. */ +@Immutable +data class WeatherColors( + val card: Color, + val onCard: Color, + val onCardMuted: Color, + val fillPrimary: Color, + val onFillPrimary: Color, + val fillSecondary: Color, + val onFillSecondary: Color, + val fillTertiary: Color, + val onFillTertiary: Color, + val pill: Color, + val pillSelected: Color, + val onPillSelected: Color, + val nowChip: Color, + val onNowChip: Color, + val precipText: Color, + val hill: Color, + val dark: Boolean, +) + +private val LightWeather = WeatherColors( + card = WColor.card, onCard = WColor.onCard, onCardMuted = WColor.onCardMuted, + fillPrimary = WColor.fillPrimary, onFillPrimary = WColor.onFillPrimary, + fillSecondary = WColor.fillSecondary, onFillSecondary = WColor.onFillSecondary, + fillTertiary = WColor.fillTertiary, onFillTertiary = WColor.onFillTertiary, + pill = WColor.pill, pillSelected = WColor.pillSelected, onPillSelected = WColor.onPillSelected, + nowChip = WColor.nowChip, onNowChip = WColor.onNowChip, precipText = WColor.precipText, + hill = WColor.sceneHill, dark = false, +) + +private val DarkWeather = WeatherColors( + card = WColor.darkCard, onCard = WColor.darkOnCard, onCardMuted = WColor.darkOnCardMuted, + fillPrimary = WColor.darkFillPrimary, onFillPrimary = WColor.darkOnFillPrimary, + fillSecondary = WColor.darkFillOlive, onFillSecondary = WColor.darkOnCard, + fillTertiary = WColor.darkFillTertiary, onFillTertiary = WColor.darkOnFillTertiary, + pill = WColor.darkPill, pillSelected = WColor.pillSelected, onPillSelected = WColor.onPillSelected, + nowChip = WColor.darkFillPrimary, onNowChip = WColor.darkOnFillPrimary, precipText = WColor.darkPrecipText, + hill = WColor.darkSceneHill, dark = true, +) + +val LocalWeatherColors = staticCompositionLocalOf { LightWeather } + +private val LightScheme = lightColorScheme( + primary = WColor.accent, onPrimary = WColor.onAccent, + primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer, + secondaryContainer = WColor.accentContainer, onSecondaryContainer = WColor.onAccentContainer, + background = WColor.bg, onBackground = WColor.text, + surface = WColor.surface, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted, + surfaceContainer = WColor.surfaceContainer, surfaceContainerHigh = WColor.surfaceContainerHigh, + outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger, +) + +private val DarkScheme = darkColorScheme( + primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent, + secondaryContainer = WColor.darkFillPrimary, onSecondaryContainer = WColor.darkOnFillPrimary, + background = WColor.darkBg, onBackground = WColor.darkText, + surface = WColor.darkSurface, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted, + surfaceContainer = WColor.darkSurfaceContainer, surfaceContainerHigh = WColor.darkSurfaceContainer, + outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger, +) + +private val AppTypography = Typography( + displayLarge = WType.display, + headlineLarge = WType.dayRange, + headlineMedium = WType.headline, + titleLarge = WType.heroCondition, + titleMedium = WType.cardTitle, + titleSmall = WType.title, + bodyLarge = WType.body, + bodyMedium = WType.bodySm, + labelLarge = WType.cardTitle, + labelMedium = WType.label, + labelSmall = WType.labelSm, +) + +@Composable +fun WeatherTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) { + val dark = when (mode) { + ThemeMode.SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + CompositionLocalProvider(LocalWeatherColors provides if (dark) DarkWeather else LightWeather) { + MaterialTheme(colorScheme = if (dark) DarkScheme else LightScheme, typography = AppTypography, content = content) + } +} diff --git a/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt new file mode 100644 index 0000000..3eaf9db --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/ui/theme/Tokens.kt @@ -0,0 +1,157 @@ +// Generated from tokens.json by gen_theme.py. Do not edit by hand. +package app.wetter.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object WColor { + val bg = Color(0xFFF8F9FF) + val surface = Color(0xFFFFFFFF) + val surfaceContainer = Color(0xFFECEEF4) + val surfaceContainerHigh = Color(0xFFE1E3EA) + val border = Color(0xFFC3C6CF) + val borderInput = Color(0xFF73777F) + val text = Color(0xFF191C20) + val textMuted = Color(0xFF43474E) + val accent = Color(0xFF2A5EA8) + val onAccent = Color(0xFFFFFFFF) + val accentContainer = Color(0xFFD6E3FF) + val onAccentContainer = Color(0xFF001B3E) + val danger = Color(0xFFBA1A1A) + val warning = Color(0xFF8A5100) + val success = Color(0xFF2E6B30) + val darkBg = Color(0xFF111318) + val darkSurface = Color(0xFF1D2024) + val darkSurfaceContainer = Color(0xFF272A2F) + val darkBorder = Color(0xFF43474E) + val darkBorderInput = Color(0xFF8D9199) + val darkText = Color(0xFFE2E2E9) + val darkTextMuted = Color(0xFFC3C6CF) + val darkAccent = Color(0xFFA9C7FF) + val darkOnAccent = Color(0xFF003063) + val darkDanger = Color(0xFFFFB4AB) + val darkWarning = Color(0xFFFFB870) + val sceneClearDayTop = Color(0xFF7CC4EF) + val sceneClearDayBottom = Color(0xFFBFE3F7) + val sceneClearNightTop = Color(0xFF3D4F9A) + val sceneClearNightBottom = Color(0xFF5B4F8F) + val sceneCloudyDayTop = Color(0xFFC9CDD2) + val sceneCloudyDayBottom = Color(0xFFDFE2E5) + val sceneRainTop = Color(0xFF2E3D4F) + val sceneRainBottom = Color(0xFF5A6B7D) + val sceneSnowTop = Color(0xFF7C8EA3) + val sceneSnowBottom = Color(0xFFD4DDE6) + val sceneStormTop = Color(0xFF1F1A33) + val sceneStormBottom = Color(0xFF4A3F63) + val sceneFogTop = Color(0xFF8A929B) + val sceneFogBottom = Color(0xFFC4C9CE) + val onScene = Color(0xFFFFFFFF) + val onSceneMuted = Color(0xFFE8EDF3) + val onSceneDark = Color(0xFF14181D) + val sceneCard = Color(0xFF2A3644) + val sceneCardLight = Color(0xFFF2F5F8) + val alertMinor = Color(0xFFFFF2B8) + val onAlertMinor = Color(0xFF3D2F00) + val alertSevere = Color(0xFFFFDBC8) + val onAlertSevere = Color(0xFF4A1A00) + val alertExtreme = Color(0xFF93000A) + val onAlertExtreme = Color(0xFFFFFFFF) + val aqiGood = Color(0xFF2E7D32) + val aqiModerate = Color(0xFFA06A00) + val aqiBad = Color(0xFFC62828) + val uvHigh = Color(0xFFB34700) + val card = Color(0xFFFDF8F3) + val onCard = Color(0xFF1F1B17) + val onCardMuted = Color(0xFF5A524B) + val darkCard = Color(0xFF1F1714) + val darkOnCard = Color(0xFFEDE0DB) + val darkOnCardMuted = Color(0xFFC4B6B0) + val fillPrimary = Color(0xFFD3E3FD) + val onFillPrimary = Color(0xFF0B2A55) + val fillSecondary = Color(0xFFE9DDFF) + val onFillSecondary = Color(0xFF2A1A4D) + val fillTertiary = Color(0xFFC8E6C2) + val onFillTertiary = Color(0xFF0F3A12) + val darkFillPrimary = Color(0xFFA8C7FA) + val darkOnFillPrimary = Color(0xFF062E6F) + val darkFillTertiary = Color(0xFF2D5A27) + val darkOnFillTertiary = Color(0xFFB7F0AC) + val darkFillOlive = Color(0xFF3E4A1E) + val pill = Color(0xFFF1EBE5) + val darkPill = Color(0xFF2B221E) + val pillSelected = Color(0xFFA8C7FA) + val onPillSelected = Color(0xFF0B2A55) + val nowChip = Color(0xFF4E4038) + val onNowChip = Color(0xFFFFFFFF) + val precipText = Color(0xFF2A5EA8) + val darkPrecipText = Color(0xFFA8C7FA) + val nowcastDot = Color(0xFF2E7D32) + val iconSun = Color(0xFFFFB02E) + val iconSunCore = Color(0xFFFF9A1F) + val iconMoon = Color(0xFFA9C4FF) + val iconCloud = Color(0xFFF4F6F9) + val iconCloudShade = Color(0xFFCFD6DF) + val iconCloudDark = Color(0xFF9AA5B3) + val iconDrop = Color(0xFF3D7BF0) + val iconSnow = Color(0xFFFFFFFF) + val iconSnowOutline = Color(0xFF9FB3CC) + val iconBolt = Color(0xFFFFC93C) + val iconFog = Color(0xFFC3CAD3) + val sceneGlowSun = Color(0xFFFFF3C4) + val sceneCloud = Color(0xFFFFFFFF) + val sceneHill = Color(0xFF9FC5E6) + val darkSceneHill = Color(0xFF2F3F78) + val rangeCold = Color(0xFF6FA8FF) + val rangeWarm = Color(0xFFFFB02E) + val aqiBar1 = Color(0xFF4FD1C5) + val aqiBar2 = Color(0xFF9CCC65) + val aqiBar3 = Color(0xFFFFD54F) + val aqiBar4 = Color(0xFFFF8A65) + val aqiBar5 = Color(0xFFE53935) + val aqiBar6 = Color(0xFF8E24AA) + val uvDotLow = Color(0xFF5CCF8A) + val uvDotOff = Color(0xFF6B5A52) +} + +object WType { + val labelSm = TextStyle(fontSize = 11.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500) + val label = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500) + val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400) + val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400) + val title = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500) + val headline = TextStyle(fontSize = 24.sp, lineHeight = 32.sp, fontWeight = FontWeight.W500) + val metric = TextStyle(fontSize = 32.sp, lineHeight = 40.sp, fontWeight = FontWeight.W500) + val display = TextStyle(fontSize = 112.sp, lineHeight = 112.sp, fontWeight = FontWeight.W700) + val displayWidget = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400) + val dayRange = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600) + val heroCondition = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400) + val cardTitle = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W500) +} + +object WSpace { + val s0 = 0.dp + val s2 = 2.dp + val s4 = 4.dp + val s8 = 8.dp + val s12 = 12.dp + val s16 = 16.dp + val s20 = 20.dp + val s24 = 24.dp + val s32 = 32.dp + val s48 = 48.dp +} + +object WRadius { + val xs = 8.dp + val sm = 12.dp + val md = 16.dp + val lg = 24.dp + val xl = 28.dp + val widget = 24.dp + val pill = 999.dp + val card = 28.dp + val dayPill = 24.dp +} diff --git a/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt new file mode 100644 index 0000000..e0fa771 --- /dev/null +++ b/wetter/app/src/main/java/app/wetter/work/RefreshWorker.kt @@ -0,0 +1,47 @@ +package app.wetter.work + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import app.wetter.data.Graph +import app.wetter.widget.updateAllWidgets +import java.util.concurrent.TimeUnit + +/** Refreshes every location, then redraws the widgets. */ +class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + Graph.init(applicationContext) + val force = inputData.getBoolean(KEY_FORCE, false) + runCatching { Graph.repo.refreshAll(force) } + updateAllWidgets(applicationContext) + return Result.success() + } + + companion object { + private const val PERIODIC = "refresh" + private const val NOW = "refresh-now" + private const val KEY_FORCE = "force" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(30, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).setRequiresBatteryNotLow(true).build()) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun runNow(context: Context, force: Boolean = true) { + val request = OneTimeWorkRequestBuilder() + .setInputData(androidx.work.workDataOf(KEY_FORCE to force)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(NOW, ExistingWorkPolicy.REPLACE, request) + } + } +} diff --git a/wetter/app/src/main/res/drawable/ic_launcher.xml b/wetter/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..72c2b93 --- /dev/null +++ b/wetter/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/wetter/app/src/main/res/drawable/ic_refresh.xml b/wetter/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..5818626 --- /dev/null +++ b/wetter/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,3 @@ + + + diff --git a/wetter/app/src/main/res/values/strings.xml b/wetter/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0e23a74 --- /dev/null +++ b/wetter/app/src/main/res/values/strings.xml @@ -0,0 +1,121 @@ + + + Wetter + + Sonnig + Klar + Teils bewölkt + Bedeckt + Nebel + Nieselregen + Regen + Schnee + Schneeregen + Gewitter + + Gefühlt %1$s + Max. %1$s · Min. %2$s + Mein Standort + Stand: vor %1$d Min. + Stand: vor %1$d Std. + Offline · zeige gespeicherte Daten + + Die nächsten Stunden + 10 Tage + Jetzt + Heute + Kurz gesagt + Niederschlag ab %1$s + Niederschlag bis %1$s + %1$s von %2$s bis %3$s + leicht + mäßig + stark + Leichter Niederschlag + Mäßiger Niederschlag + Starker Niederschlag + + Wind + UV + Sicht + Feuchte + Luftdruck + Sonne + Luftqualität + Pollen + Aus %1$s + Taupunkt + – – + + + NNOOSOSSWWNW + + + NiedrigMittelHochSehr hochExtrem + + + Sehr gute LuftGute LuftMittelmäßige LuftSchlechte LuftSehr schlechte LuftExtrem schlechte Luft + + + ErleBirkeGräserBeifußOliveAmbrosia + + Kaum Pollen + + Ab %1$s Uhr Regen. + Ab %1$s Uhr Schnee. + Ab %1$s Uhr Gewitter möglich. + Gerade nass draußen, Schirm mitnehmen. + Gerade Niederschlag, ab etwa %1$s trocken. + Bis heute Abend bleibt es trocken. + Den Rest des Tages bleibt es trocken. + Morgen %1$d° wärmer. + Morgen %1$d° kühler. + Morgen ähnlich temperiert. + UV hoch: Sonnenschutz zwischen %1$s und %2$s Uhr. + Böen bis %1$s. + + Orte + Stadt oder Postleitzahl + Kein Ort gefunden + Suche fehlgeschlagen + Erneut versuchen + Speichern + Gespeichert + %1$s entfernt + Rückgängig + Entfernen + Zurück + Einstellungen + Orte öffnen + + Wetter für deinen Standort + Erlaube den ungefähren Standort, um das Wetter dort zu sehen. Er verlässt das Gerät nur auf 2 Nachkommastellen gerundet, für die Wetterabfrage. + Standort erlauben + Lieber einen Ort suchen + Wetterdaten konnten nicht geladen werden + + Einheiten + Temperatur + Windgeschwindigkeit + Niederschlag + Luftdruck + Sichtweite + Darstellung + Design + System + Hell + Dunkel + Animierter Himmel + Wird im Energiesparmodus automatisch angehalten + Zusammenfassung anzeigen + Datenquellen + Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende + + Wetter jetzt + Temperatur und Wetterlage auf einen Blick + Vorhersage + Jetzt, die nächsten Stunden und Tage + Ort wählen + Mein Standort (folgt dir) + … + diff --git a/wetter/app/src/main/res/xml/widget_forecast.xml b/wetter/app/src/main/res/xml/widget_forecast.xml new file mode 100644 index 0000000..384fd96 --- /dev/null +++ b/wetter/app/src/main/res/xml/widget_forecast.xml @@ -0,0 +1,10 @@ + + diff --git a/wetter/app/src/main/res/xml/widget_now.xml b/wetter/app/src/main/res/xml/widget_now.xml new file mode 100644 index 0000000..1b1a2a7 --- /dev/null +++ b/wetter/app/src/main/res/xml/widget_now.xml @@ -0,0 +1,10 @@ + + diff --git a/wetter/build.gradle.kts b/wetter/build.gradle.kts new file mode 100644 index 0000000..437470f --- /dev/null +++ b/wetter/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("com.android.application") version "8.13.0" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false + id("com.google.devtools.ksp") version "2.2.20-2.0.3" apply false + id("io.github.takahirom.roborazzi") version "1.46.1" apply false +} diff --git a/wetter/gradle.properties b/wetter/gradle.properties new file mode 100644 index 0000000..f97184b --- /dev/null +++ b/wetter/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/wetter/gradle/wrapper/gradle-wrapper.jar b/wetter/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/wetter/gradle/wrapper/gradle-wrapper.jar differ diff --git a/wetter/gradle/wrapper/gradle-wrapper.properties b/wetter/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/wetter/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/wetter/gradlew b/wetter/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/wetter/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/wetter/gradlew.bat b/wetter/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/wetter/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/wetter/settings.gradle.kts b/wetter/settings.gradle.kts new file mode 100644 index 0000000..a5bcf7e --- /dev/null +++ b/wetter/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "Wetter" +include(":app")