build: shell, data layer, theme, animated scene, components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
@@ -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")) }
|
||||
Vendored
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".WetterApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name=".widget.NowWidgetReceiver" android:exported="true" android:label="@string/widget_now_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_now" />
|
||||
</receiver>
|
||||
<receiver android:name=".widget.ForecastWidgetReceiver" android:exported="true" android:label="@string/widget_forecast_name">
|
||||
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
|
||||
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_forecast" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<List<LocationEntity>>
|
||||
|
||||
@Query("SELECT * FROM locations ORDER BY is_current DESC, sort_order, id")
|
||||
suspend fun locationsOnce(): List<LocationEntity>
|
||||
|
||||
@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<Long>) = ids.forEachIndexed { i, id -> setSortOrder(id, i) }
|
||||
|
||||
@Query("SELECT * FROM forecast_cache WHERE location_id = :id")
|
||||
fun forecast(id: Long): Flow<ForecastCacheEntity?>
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, Double>,
|
||||
)
|
||||
|
||||
data class Forecast(
|
||||
val zone: ZoneId,
|
||||
val fetchedAt: Long,
|
||||
val current: Current,
|
||||
val hourly: List<Hour>,
|
||||
val daily: List<Day>,
|
||||
val minutely: List<Quarter>,
|
||||
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<Hour> {
|
||||
val start = now.withMinute(0).withSecond(0).withNano(0)
|
||||
return hourly.filter { !it.time.isBefore(start) }.take(count)
|
||||
}
|
||||
|
||||
fun hoursOf(date: LocalDate): List<Hour> = 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,
|
||||
)
|
||||
@@ -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<Place> {
|
||||
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
|
||||
@@ -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<Settings> = 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 <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
|
||||
@@ -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<List<LocationEntity>> = dao.locations()
|
||||
|
||||
fun forecast(locationId: Long): Flow<Forecast?> = 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<Place> = 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<Long>) = 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
|
||||
}
|
||||
}
|
||||
@@ -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<String, Double>, 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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)]
|
||||
@@ -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<Hour>, 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<Day>,
|
||||
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<Quarter>, 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)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<Color, Color> = 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<P>, val clouds: List<P>, val drops: List<P>) {
|
||||
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<Float>) = 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<RefreshWorker>(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<RefreshWorker>()
|
||||
.setInputData(androidx.work.workDataOf(KEY_FORCE to force))
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(NOW, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#FF7CC4EF" android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#FFFFB02E" android:pathData="M64,40m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"/>
|
||||
<path android:fillColor="#FFF4F6F9" android:pathData="M30,74h44a12,12 0,0 0,0 -24a16,16 0,0 0,-30 -4a13,13 0,0 0,-14 28z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M17.65,6.35A7.96,7.96 0,0 0,12 4a8,8 0,1 0,7.73 10h-2.08A6,6 0,1 1,12 6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Wetter</string>
|
||||
|
||||
<string name="cond_clear_day">Sonnig</string>
|
||||
<string name="cond_clear_night">Klar</string>
|
||||
<string name="cond_partly">Teils bewölkt</string>
|
||||
<string name="cond_cloudy">Bedeckt</string>
|
||||
<string name="cond_fog">Nebel</string>
|
||||
<string name="cond_drizzle">Nieselregen</string>
|
||||
<string name="cond_rain">Regen</string>
|
||||
<string name="cond_snow">Schnee</string>
|
||||
<string name="cond_sleet">Schneeregen</string>
|
||||
<string name="cond_storm">Gewitter</string>
|
||||
|
||||
<string name="feels_like">Gefühlt %1$s</string>
|
||||
<string name="high_low">Max. %1$s · Min. %2$s</string>
|
||||
<string name="current_location">Mein Standort</string>
|
||||
<string name="updated_ago">Stand: vor %1$d Min.</string>
|
||||
<string name="updated_hours">Stand: vor %1$d Std.</string>
|
||||
<string name="offline">Offline · zeige gespeicherte Daten</string>
|
||||
|
||||
<string name="hourly">Die nächsten Stunden</string>
|
||||
<string name="daily">10 Tage</string>
|
||||
<string name="now">Jetzt</string>
|
||||
<string name="today">Heute</string>
|
||||
<string name="brief_title">Kurz gesagt</string>
|
||||
<string name="nowcast_title_rain">Niederschlag ab %1$s</string>
|
||||
<string name="nowcast_title_ongoing">Niederschlag bis %1$s</string>
|
||||
<string name="nowcast_sub">%1$s von %2$s bis %3$s</string>
|
||||
<string name="nowcast_light">leicht</string>
|
||||
<string name="nowcast_moderate">mäßig</string>
|
||||
<string name="nowcast_heavy">stark</string>
|
||||
<string name="precip_light">Leichter Niederschlag</string>
|
||||
<string name="precip_moderate">Mäßiger Niederschlag</string>
|
||||
<string name="precip_heavy">Starker Niederschlag</string>
|
||||
|
||||
<string name="card_wind">Wind</string>
|
||||
<string name="card_uv">UV</string>
|
||||
<string name="card_visibility">Sicht</string>
|
||||
<string name="card_humidity">Feuchte</string>
|
||||
<string name="card_pressure">Luftdruck</string>
|
||||
<string name="card_sun">Sonne</string>
|
||||
<string name="card_aqi">Luftqualität</string>
|
||||
<string name="card_pollen">Pollen</string>
|
||||
<string name="wind_from">Aus %1$s</string>
|
||||
<string name="dew_point">Taupunkt</string>
|
||||
<string name="no_data">– –</string>
|
||||
|
||||
<string-array name="compass">
|
||||
<item>N</item><item>NO</item><item>O</item><item>SO</item><item>S</item><item>SW</item><item>W</item><item>NW</item>
|
||||
</string-array>
|
||||
<string-array name="uv_level">
|
||||
<item>Niedrig</item><item>Mittel</item><item>Hoch</item><item>Sehr hoch</item><item>Extrem</item>
|
||||
</string-array>
|
||||
<string-array name="aqi_level">
|
||||
<item>Sehr gute Luft</item><item>Gute Luft</item><item>Mittelmäßige Luft</item><item>Schlechte Luft</item><item>Sehr schlechte Luft</item><item>Extrem schlechte Luft</item>
|
||||
</string-array>
|
||||
<string-array name="pollen_names">
|
||||
<item>Erle</item><item>Birke</item><item>Gräser</item><item>Beifuß</item><item>Olive</item><item>Ambrosia</item>
|
||||
</string-array>
|
||||
<string name="pollen_none">Kaum Pollen</string>
|
||||
|
||||
<string name="brief_rain_from">Ab %1$s Uhr Regen.</string>
|
||||
<string name="brief_snow_from">Ab %1$s Uhr Schnee.</string>
|
||||
<string name="brief_storm_from">Ab %1$s Uhr Gewitter möglich.</string>
|
||||
<string name="brief_wet_now">Gerade nass draußen, Schirm mitnehmen.</string>
|
||||
<string name="brief_wet_now_until">Gerade Niederschlag, ab etwa %1$s trocken.</string>
|
||||
<string name="brief_dry">Bis heute Abend bleibt es trocken.</string>
|
||||
<string name="brief_dry_rest">Den Rest des Tages bleibt es trocken.</string>
|
||||
<string name="brief_warmer">Morgen %1$d° wärmer.</string>
|
||||
<string name="brief_colder">Morgen %1$d° kühler.</string>
|
||||
<string name="brief_same">Morgen ähnlich temperiert.</string>
|
||||
<string name="brief_uv">UV hoch: Sonnenschutz zwischen %1$s und %2$s Uhr.</string>
|
||||
<string name="brief_wind">Böen bis %1$s.</string>
|
||||
|
||||
<string name="locations">Orte</string>
|
||||
<string name="search_hint">Stadt oder Postleitzahl</string>
|
||||
<string name="search_empty">Kein Ort gefunden</string>
|
||||
<string name="search_error">Suche fehlgeschlagen</string>
|
||||
<string name="retry">Erneut versuchen</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="saved">Gespeichert</string>
|
||||
<string name="deleted">%1$s entfernt</string>
|
||||
<string name="undo">Rückgängig</string>
|
||||
<string name="delete">Entfernen</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="open_locations">Orte öffnen</string>
|
||||
|
||||
<string name="perm_title">Wetter für deinen Standort</string>
|
||||
<string name="perm_body">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.</string>
|
||||
<string name="perm_allow">Standort erlauben</string>
|
||||
<string name="perm_search">Lieber einen Ort suchen</string>
|
||||
<string name="loading_error">Wetterdaten konnten nicht geladen werden</string>
|
||||
|
||||
<string name="set_units">Einheiten</string>
|
||||
<string name="set_temp">Temperatur</string>
|
||||
<string name="set_wind">Windgeschwindigkeit</string>
|
||||
<string name="set_precip">Niederschlag</string>
|
||||
<string name="set_pressure">Luftdruck</string>
|
||||
<string name="set_distance">Sichtweite</string>
|
||||
<string name="set_look">Darstellung</string>
|
||||
<string name="set_theme">Design</string>
|
||||
<string name="theme_system">System</string>
|
||||
<string name="theme_light">Hell</string>
|
||||
<string name="theme_dark">Dunkel</string>
|
||||
<string name="set_animations">Animierter Himmel</string>
|
||||
<string name="set_animations_sub">Wird im Energiesparmodus automatisch angehalten</string>
|
||||
<string name="set_brief">Zusammenfassung anzeigen</string>
|
||||
<string name="set_about">Datenquellen</string>
|
||||
<string name="attribution">Wetterdaten: Open-Meteo.com (CC BY 4.0) · Ortsnamen: © OpenStreetMap-Mitwirkende</string>
|
||||
|
||||
<string name="widget_now_name">Wetter jetzt</string>
|
||||
<string name="widget_now_desc">Temperatur und Wetterlage auf einen Blick</string>
|
||||
<string name="widget_forecast_name">Vorhersage</string>
|
||||
<string name="widget_forecast_desc">Jetzt, die nächsten Stunden und Tage</string>
|
||||
<string name="widget_pick">Ort wählen</string>
|
||||
<string name="widget_follow">Mein Standort (folgt dir)</string>
|
||||
<string name="widget_loading">…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_forecast_desc"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="250dp" android:minHeight="110dp"
|
||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||
android:minResizeWidth="250dp" android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="3600000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/widget_now_desc"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="110dp" android:minHeight="48dp"
|
||||
android:targetCellWidth="2" android:targetCellHeight="2"
|
||||
android:minResizeWidth="110dp" android:minResizeHeight="48dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="3600000"
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+251
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user