Merge branch 'main' into l10n_main

Signed-off-by: Pranshul <pranshul.devmain@gmail.com>
This commit is contained in:
Pranshul authored and GitHub committed 2026-07-30 16:10:34 +05:30
commit cc71c65b72
88 files changed
+5014 -238

No files matched your search

+9
View File
@@ -24,6 +24,9 @@ val mfKey = providers.gradleProperty("MF_KEY").orNull ?: System.getenv("MF_KEY")
?: ""
val accuKey = providers.gradleProperty("ACCU_KEY").orNull ?: System.getenv("ACCU_KEY")
?: ""
val weatherapiKey =
providers.gradleProperty("WEATHERAPI_KEY").orNull ?: System.getenv("WEATHERAPI_KEY")
?: ""
val keystoreFile = file("../keystore/release.jks")
val hasKeystore = keystoreFile.exists()
@@ -60,6 +63,12 @@ android {
"ACCU_KEY",
"\"$accuKey\""
)
buildConfigField(
"String",
"WEATHERAPI_KEY",
"\"$weatherapiKey\""
)
}
signingConfigs {
@@ -0,0 +1,916 @@
{
"formatVersion": 1,
"database": {
"version": 49,
"identityHash": "41f3af0cd4a758a20290098071993b9c",
"entities": [
{
"tableName": "weather_locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `timezone` TEXT NOT NULL, `source` TEXT NOT NULL, `state` TEXT, `isFavorite` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `countryCode` TEXT, `isDefault` INTEGER NOT NULL, `isDeviceLocation` INTEGER NOT NULL, `alertSource` TEXT NOT NULL, `airQualitySource` TEXT NOT NULL, `customName` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "country",
"columnName": "country",
"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": "source",
"columnName": "source",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT"
},
{
"fieldPath": "isFavorite",
"columnName": "isFavorite",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "countryCode",
"columnName": "countryCode",
"affinity": "TEXT"
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeviceLocation",
"columnName": "isDeviceLocation",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alertSource",
"columnName": "alertSource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "airQualitySource",
"columnName": "airQualitySource",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "customName",
"columnName": "customName",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "weather_current",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `temperature` REAL, `humidity` REAL NOT NULL, `windSpeed` REAL, `windDirection` TEXT, `pressureMsl` REAL, `visibility` INTEGER, `cloudCover` REAL, `uvIndex` REAL, `weatherCondition` TEXT NOT NULL, `feelsLike` REAL, `time` INTEGER NOT NULL, `dewPoint` REAL, `utcOffsetSeconds` INTEGER, `lastUpdatedInMilli` INTEGER NOT NULL, PRIMARY KEY(`locationId`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "temperature",
"columnName": "temperature",
"affinity": "REAL"
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL"
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "TEXT"
},
{
"fieldPath": "pressureMsl",
"columnName": "pressureMsl",
"affinity": "REAL"
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER"
},
{
"fieldPath": "cloudCover",
"columnName": "cloudCover",
"affinity": "REAL"
},
{
"fieldPath": "uvIndex",
"columnName": "uvIndex",
"affinity": "REAL"
},
{
"fieldPath": "weatherCondition",
"columnName": "weatherCondition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "feelsLike",
"columnName": "feelsLike",
"affinity": "REAL"
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dewPoint",
"columnName": "dewPoint",
"affinity": "REAL"
},
{
"fieldPath": "utcOffsetSeconds",
"columnName": "utcOffsetSeconds",
"affinity": "INTEGER"
},
{
"fieldPath": "lastUpdatedInMilli",
"columnName": "lastUpdatedInMilli",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId"
]
},
"indices": [
{
"name": "index_weather_current_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_weather_current_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "weather_hourly",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `temperature` REAL, `windSpeed` REAL, `windDirection` TEXT, `rain` REAL NOT NULL, `snowfall` REAL, `uvIndex` REAL, `weatherCondition` TEXT NOT NULL, `pressureMsl` REAL, `visibility` INTEGER, `humidity` REAL, `dewPoint` REAL, `time` INTEGER NOT NULL, `precipitationProbability` INTEGER, PRIMARY KEY(`locationId`, `time`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "temperature",
"columnName": "temperature",
"affinity": "REAL"
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL"
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "TEXT"
},
{
"fieldPath": "rain",
"columnName": "rain",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snowfall",
"columnName": "snowfall",
"affinity": "REAL"
},
{
"fieldPath": "uvIndex",
"columnName": "uvIndex",
"affinity": "REAL"
},
{
"fieldPath": "weatherCondition",
"columnName": "weatherCondition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pressureMsl",
"columnName": "pressureMsl",
"affinity": "REAL"
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER"
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL"
},
{
"fieldPath": "dewPoint",
"columnName": "dewPoint",
"affinity": "REAL"
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "precipitationProbability",
"columnName": "precipitationProbability",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId",
"time"
]
},
"indices": [
{
"name": "index_weather_hourly_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_weather_hourly_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "weather_daily",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `temperatureMin` REAL, `temperatureMax` REAL, `windSpeed` REAL, `windDirection` TEXT, `rainSum` REAL NOT NULL, `snowfallSum` REAL, `uvIndexMax` REAL, `weatherCondition` TEXT NOT NULL, `time` INTEGER NOT NULL, `precipitationProbabilityMax` INTEGER, `pressureMsl` REAL, `visibility` INTEGER, `humidity` REAL, `dewPoint` REAL, `sunrise` INTEGER NOT NULL, `sunset` INTEGER NOT NULL, `moonrise` INTEGER NOT NULL, `moonset` INTEGER NOT NULL, `moonPhase` TEXT NOT NULL, `dawn` INTEGER NOT NULL, `dusk` INTEGER NOT NULL, PRIMARY KEY(`locationId`, `time`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "temperatureMin",
"columnName": "temperatureMin",
"affinity": "REAL"
},
{
"fieldPath": "temperatureMax",
"columnName": "temperatureMax",
"affinity": "REAL"
},
{
"fieldPath": "windSpeed",
"columnName": "windSpeed",
"affinity": "REAL"
},
{
"fieldPath": "windDirection",
"columnName": "windDirection",
"affinity": "TEXT"
},
{
"fieldPath": "rainSum",
"columnName": "rainSum",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "snowfallSum",
"columnName": "snowfallSum",
"affinity": "REAL"
},
{
"fieldPath": "uvIndexMax",
"columnName": "uvIndexMax",
"affinity": "REAL"
},
{
"fieldPath": "weatherCondition",
"columnName": "weatherCondition",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "precipitationProbabilityMax",
"columnName": "precipitationProbabilityMax",
"affinity": "INTEGER"
},
{
"fieldPath": "pressureMsl",
"columnName": "pressureMsl",
"affinity": "REAL"
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER"
},
{
"fieldPath": "humidity",
"columnName": "humidity",
"affinity": "REAL"
},
{
"fieldPath": "dewPoint",
"columnName": "dewPoint",
"affinity": "REAL"
},
{
"fieldPath": "sunrise",
"columnName": "sunrise",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sunset",
"columnName": "sunset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "moonrise",
"columnName": "moonrise",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "moonset",
"columnName": "moonset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "moonPhase",
"columnName": "moonPhase",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "dawn",
"columnName": "dawn",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dusk",
"columnName": "dusk",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId",
"time"
]
},
"indices": [
{
"name": "index_weather_daily_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_weather_daily_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "weather_units",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `tempUnit` TEXT NOT NULL, `windUnit` TEXT NOT NULL, `distanceUnit` TEXT NOT NULL, `pressureUnit` TEXT NOT NULL, `precipitationUnit` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tempUnit",
"columnName": "tempUnit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "windUnit",
"columnName": "windUnit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "distanceUnit",
"columnName": "distanceUnit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pressureUnit",
"columnName": "pressureUnit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "precipitationUnit",
"columnName": "precipitationUnit",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "weather_blocks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isDaily` INTEGER NOT NULL, `type` TEXT NOT NULL, `isHidden` INTEGER NOT NULL, `position` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDaily",
"columnName": "isDaily",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHidden",
"columnName": "isHidden",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "air_quality_current",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `usAqi` INTEGER, `pm10` REAL, `pm25` REAL, `carbonMonoxide` REAL, `nitrogenDioxide` REAL, `sulphurDioxide` REAL, `ozone` REAL, `lastUpdatedInMilli` INTEGER NOT NULL, PRIMARY KEY(`locationId`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "usAqi",
"columnName": "usAqi",
"affinity": "INTEGER"
},
{
"fieldPath": "pm10",
"columnName": "pm10",
"affinity": "REAL"
},
{
"fieldPath": "pm25",
"columnName": "pm25",
"affinity": "REAL"
},
{
"fieldPath": "carbonMonoxide",
"columnName": "carbonMonoxide",
"affinity": "REAL"
},
{
"fieldPath": "nitrogenDioxide",
"columnName": "nitrogenDioxide",
"affinity": "REAL"
},
{
"fieldPath": "sulphurDioxide",
"columnName": "sulphurDioxide",
"affinity": "REAL"
},
{
"fieldPath": "ozone",
"columnName": "ozone",
"affinity": "REAL"
},
{
"fieldPath": "lastUpdatedInMilli",
"columnName": "lastUpdatedInMilli",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId"
]
},
"indices": [
{
"name": "index_air_quality_current_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_air_quality_current_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "nws_location_gridpoints",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `officeId` TEXT NOT NULL, `gridX` INTEGER NOT NULL, `gridY` INTEGER NOT NULL, `stationIdentifier` TEXT NOT NULL, `lastUpdatedMilli` INTEGER NOT NULL, PRIMARY KEY(`locationId`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "officeId",
"columnName": "officeId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "gridX",
"columnName": "gridX",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gridY",
"columnName": "gridY",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "stationIdentifier",
"columnName": "stationIdentifier",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdatedMilli",
"columnName": "lastUpdatedMilli",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId"
]
},
"indices": [
{
"name": "index_nws_location_gridpoints_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_nws_location_gridpoints_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "github",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `currentTag` TEXT NOT NULL, `lastFetchedTag` TEXT NOT NULL, `lastFetchedTime` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "currentTag",
"columnName": "currentTag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastFetchedTag",
"columnName": "lastFetchedTag",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastFetchedTime",
"columnName": "lastFetchedTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "air_quality_hourly",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `locationId` TEXT NOT NULL, `pm10` REAL, `pm25` REAL, `carbonMonoxide` REAL, `nitrogenDioxide` REAL, `sulphurDioxide` REAL, `ozone` REAL, `time` INTEGER NOT NULL, FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "pm10",
"columnName": "pm10",
"affinity": "REAL"
},
{
"fieldPath": "pm25",
"columnName": "pm25",
"affinity": "REAL"
},
{
"fieldPath": "carbonMonoxide",
"columnName": "carbonMonoxide",
"affinity": "REAL"
},
{
"fieldPath": "nitrogenDioxide",
"columnName": "nitrogenDioxide",
"affinity": "REAL"
},
{
"fieldPath": "sulphurDioxide",
"columnName": "sulphurDioxide",
"affinity": "REAL"
},
{
"fieldPath": "ozone",
"columnName": "ozone",
"affinity": "REAL"
},
{
"fieldPath": "time",
"columnName": "time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_air_quality_hourly_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_air_quality_hourly_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "accu_locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`locationId` TEXT NOT NULL, `cityKey` TEXT NOT NULL, PRIMARY KEY(`locationId`), FOREIGN KEY(`locationId`) REFERENCES `weather_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "locationId",
"columnName": "locationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cityKey",
"columnName": "cityKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"locationId"
]
},
"indices": [
{
"name": "index_accu_locations_locationId",
"unique": false,
"columnNames": [
"locationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_accu_locations_locationId` ON `${TABLE_NAME}` (`locationId`)"
}
],
"foreignKeys": [
{
"table": "weather_locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"locationId"
],
"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, '41f3af0cd4a758a20290098071993b9c')"
]
}
}
@@ -23,6 +23,8 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.openmeteo.
import com.pranshulgg.weather_master_app.core.network.sources.weather.openmeteo.OpenMeteoRepository
import com.pranshulgg.weather_master_app.data.local.WeatherMasterDatabase
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.dao.github.GithubDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherBlocksDao
@@ -79,8 +81,12 @@ object AppModule {
@Singleton
fun provideWeatherDataReconcilerRepository(
nwsDao: NwsDao,
locationsDao: LocationsDao
): WeatherDataReconcilerRepository = WeatherDataReconcilerRepository(nwsDao, locationsDao)
locationsDao: LocationsDao,
accuDao: AccuDao,
airQualityDao: AirQualityDao,
alertsDao: AlertsDao
): WeatherDataReconcilerRepository =
WeatherDataReconcilerRepository(nwsDao, locationsDao, accuDao, airQualityDao, alertsDao)
@Provides
@Singleton
@@ -93,5 +99,5 @@ object AppModule {
@Singleton
fun provideNominatimRepository(api: NominatimApi): NominatimRepository =
NominatimRepository(api)
}
@@ -34,4 +34,10 @@ object DaoModule {
@Provides
fun provideGithubDao(db: WeatherMasterDatabase) = db.githubDao()
@Provides
fun provideAccuDao(db: WeatherMasterDatabase) = db.accuDao()
@Provides
fun provideAlertsDao(db: WeatherMasterDatabase) = db.alertsDao()
}
@@ -2,7 +2,10 @@ package com.pranshulgg.weather_master_app.core.di.network
import com.pranshulgg.weather_master_app.core.network.github.GithubApi
import com.pranshulgg.weather_master_app.core.network.sources.address.nominatim.NominatimApi
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.AccuAqiApi
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiApi
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.AlertsAccuApi
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.AlertsWeatherApi
import com.pranshulgg.weather_master_app.core.network.sources.search.geonames.GeoNamesSearchApi
import com.pranshulgg.weather_master_app.core.network.sources.search.geonames.timezone.GeoNamesTimezoneApi
import com.pranshulgg.weather_master_app.core.network.sources.search.openmeteo.OpenMeteoSearchApi
@@ -99,4 +102,16 @@ object ApiModule {
@Provides
@Singleton
fun provideMeteoamApi(): MeteoamApi = MeteoamApi.create()
@Provides
@Singleton
fun provideAccuAqiApi(): AccuAqiApi = AccuAqiApi.create()
@Provides
@Singleton
fun provideAlertsAccuApi(): AlertsAccuApi = AlertsAccuApi.create()
@Provides
@Singleton
fun provideAlertsWeatherApi(): AlertsWeatherApi = AlertsWeatherApi.create()
}
@@ -1,7 +1,13 @@
package com.pranshulgg.weather_master_app.core.di.weather
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.AccuAqiApi
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.AccuAqiRepository
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiApi
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiRepository
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.AlertsAccuApi
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.AlertsAccuRepository
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.AlertsWeatherApi
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.AlertsWeatherApiRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.AccuApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.AccuRepository
import com.pranshulgg.weather_master_app.core.network.sources.weather.bmkg.BmkgApi
@@ -27,6 +33,8 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.openmeteo.
import com.pranshulgg.weather_master_app.core.network.sources.weather.smhi.SmhiApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.smhi.SmhiRepository
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.nws.NwsDao
@@ -133,8 +141,9 @@ object WeatherRepositoryModule {
fun provideAccuRepository(
dao: LocationsDao,
api: AccuApi,
weatherDao: WeatherDao
): AccuRepository = AccuRepository(dao, weatherDao, api)
weatherDao: WeatherDao,
accuDao: AccuDao
): AccuRepository = AccuRepository(dao, weatherDao, api, accuDao)
@Provides
@Singleton
@@ -143,4 +152,30 @@ object WeatherRepositoryModule {
api: MeteoamApi,
weatherDao: WeatherDao
): MeteoamRepository = MeteoamRepository(dao, weatherDao, api)
@Provides
@Singleton
fun provideAccuAqiRepository(
api: AccuAqiApi,
dao: AirQualityDao,
accuDao: AccuDao,
accuApi: AccuApi
): AccuAqiRepository = AccuAqiRepository(api, dao, accuDao, accuApi)
@Provides
@Singleton
fun provideAccuAlertsRepository(
api: AlertsAccuApi,
dao: AlertsDao,
accuDao: AccuDao,
accuApi: AccuApi
): AlertsAccuRepository = AlertsAccuRepository(api, dao, accuDao, accuApi)
@Provides
@Singleton
fun provideWeatherApiAlertsRepository(
api: AlertsWeatherApi,
dao: AlertsDao
): AlertsWeatherApiRepository = AlertsWeatherApiRepository(api, dao)
}
@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.core.model.domain.alerts
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
data class Alert(
val locationId: String,
val event: String,
val severity: AlertSeverity?,
val effective: Long?,
val expires: Long?,
val description: String,
val source: String?,
val lastUpdatedInMilli: Long
)
@@ -16,6 +16,6 @@ enum class AirQualitySource(
displayName = "Open Meteo",
fullName = "Open Meteo",
displayLink = "https://open-meteo.com/"
),
)
}
@@ -0,0 +1,7 @@
package com.pranshulgg.weather_master_app.core.model.weather.airquality.accu
data class AccuCityKey(
val locationId: String,
val cityKey: String
)
@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.core.model.weather.alerts
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
sealed class AlertResult {
data class Success(val alerts: List<Alert>) : AlertResult()
data class Error(val exception: Exception, val cacheAlerts: List<Alert> = emptyList()) :
AlertResult()
}
enum class AlertResultType {
RETURN_CACHE,
ERROR
}
@@ -0,0 +1,36 @@
package com.pranshulgg.weather_master_app.core.model.weather.alerts
import androidx.compose.ui.graphics.Color
import com.pranshulgg.weather_master_app.R
enum class AlertSeverity(
val label: Int,
val color: Color,
val contentColor: Color,
) {
CRITICAL(
R.string.alert_severity_critical,
Color(0xFFD32F2F),
Color(0xFFFFEBEE)
),
HIGH(
R.string.alert_severity_high,
Color(0xFFF57C00),
Color(0xFFFFF4E0)
),
MODERATE(
R.string.alert_severity_medium,
Color(0xFFFBC02D),
Color(0xFF5C4300)
),
LOW(
R.string.alert_severity_low,
Color(0xFF388E3C),
Color(0xFF0F3D0F)
),
UNKNOWN(
R.string.alert_severity_unknown,
Color(0xFF757575),
Color(0xFFF3E5F5)
)
}
@@ -17,7 +17,7 @@ class GithubRepository @Inject constructor(
if (getSavedVersionInfo != null && getSavedVersionInfo.currentTag == currentTag) {
val ageMillis = System.currentTimeMillis() - getSavedVersionInfo.lastFetchedTime
val maxAgeMillis = TimeUnit.HOURS.toMillis(24)
val maxAgeMillis = TimeUnit.HOURS.toMillis(2)
if (ageMillis < maxAgeMillis) {
return@withContext getSavedVersionInfo.lastFetchedTag != currentTag
@@ -0,0 +1,68 @@
package com.pranshulgg.weather_master_app.core.network.sources.airquality.accu
import com.pranshulgg.weather_master_app.BuildConfig
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiForecastJson
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiJson
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.json.AccuCurrentWeatherJson
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.json.AccuDailyWeatherJson
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.json.AccuHourlyWeatherJson
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.json.AccuLocationJson
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
interface AccuAqiApi {
@GET("airquality/v2/currentconditions/{locationKey}")
suspend fun fetchCurrent(
@Path("locationKey") locationKey: String,
@Query("pollutants") details: Boolean = true
): Response<AccuAqiJson>
@GET("airquality/v2/forecasts/hourly/24hour/{locationKey}")
suspend fun fetchForecast(
@Path("locationKey") locationKey: String,
@Query("pollutants") details: Boolean = true
): Response<AccuAqiForecastJson>
companion object {
const val BASE_URL = "https://api.accuweather.com/"
fun create(): AccuAqiApi {
val auth = Interceptor { chain ->
val original = chain.request()
val newUrl =
original.url.newBuilder().addQueryParameter("apikey", BuildConfig.ACCU_KEY)
.build()
val request = original.newBuilder().url(newUrl).build()
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(auth)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AccuAqiApi::class.java)
}
}
}
@@ -0,0 +1,95 @@
package com.pranshulgg.weather_master_app.core.network.sources.airquality.accu
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResult
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResultType
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.bundle.AccuAqiJsonBundle
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiApi
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.AccuApi
import com.pranshulgg.weather_master_app.core.utils.weather.cache.isCurrentAirQualitySafe
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnAirQualityCache
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.toEntity
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.repository.AirQualityRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.UnknownHostException
import javax.inject.Inject
class AccuAqiRepository @Inject constructor(
private val api: AccuAqiApi,
private val dao: AirQualityDao,
private val accuDao: AccuDao,
private val accuApi: AccuApi,
) : AirQualityRepository {
override suspend fun getAirQuality(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AirQualityResult = withContext(Dispatchers.IO) {
val cache = dao.getAirQualityForLocation(location.id)
val shouldReturnCache = shouldReturnAirQualityCache(cache, isManualRefresh, isForceRefresh)
when (shouldReturnCache) {
AirQualityResultType.RETURN_CACHE -> return@withContext AirQualityResult.Success(cache!!.toDomain())
else -> {}
}
return@withContext try {
val locationKey = accuDao.getCityKeyForLocation(location.id)?.toDomain()?.cityKey
?: accuApi.getLocationKey("${location.latitude},${location.longitude}").body()?.key
?: return@withContext AirQualityResult.Error(exception = AppException.Unknown())
val responseCurrent = api.fetchCurrent(locationKey)
val bodyCurrent = responseCurrent.body()
?: return@withContext AirQualityResult.Error(exception = UnknownHostException())
val responseForecast = api.fetchForecast(locationKey)
val bodyForecast = responseForecast.body()
?: return@withContext AirQualityResult.Error(exception = UnknownHostException())
val final = AccuAqiJsonBundle(
current = bodyCurrent,
forecast = bodyForecast
)
val domain = final.toDomain()
accuDao.insertCityKey(
AccuEntity(
locationId = location.id,
cityKey = locationKey
)
)
dao.insertAirQuality(
domain.current.toEntity(location.id),
domain.hourly.map { it.toEntity(location.id) },
location.id
)
AirQualityResult.Success(domain)
} catch (e: Exception) {
val isCacheSafe = isCurrentAirQualitySafe(cache?.toDomain())
AirQualityResult.Error(exception = e, if (isCacheSafe) cache?.toDomain() else null)
}
}
}
@@ -0,0 +1,35 @@
package com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json
import com.google.gson.annotations.SerializedName
data class AccuAqiJson(
val data: AccuAqiDataJson,
@SerializedName("epochDate") val timeSeconds: Long
)
data class AccuAqiDataJson(
val pollutants: List<AccuAqiPollutantJson>
)
data class AccuAqiPollutantJson(
val type: String,
val concentration: AccuAqiConcentrationJson
)
data class AccuAqiConcentrationJson(
val value: Double?
)
data class AccuAqiForecastJson(
val data: List<AccuAqiForecastDataJson>
)
data class AccuAqiForecastDataJson(
@SerializedName("epochDate") val timeSeconds: Long,
val pollutants: List<AccuAqiForecastPollutantJson>
)
data class AccuAqiForecastPollutantJson(
val type: String,
val concentration: AccuAqiConcentrationJson
)
@@ -0,0 +1,9 @@
package com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.bundle
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiForecastJson
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiJson
data class AccuAqiJsonBundle(
val current: AccuAqiJson,
val forecast: AccuAqiForecastJson
)
@@ -6,8 +6,10 @@ import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualit
import com.pranshulgg.weather_master_app.core.utils.weather.cache.isCurrentAirQualitySafe
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnAirQualityCache
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.sources.openmeteo.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.toEntity
import com.pranshulgg.weather_master_app.data.repository.AirQualityRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.UnknownHostException
@@ -16,16 +18,17 @@ import javax.inject.Inject
class OpenMeteoAqiRepository @Inject constructor(
private val api: OpenMeteoAqiApi,
private val dao: AirQualityDao
) {
) : AirQualityRepository {
suspend fun getAirQuality(
override suspend fun getAirQuality(
location: Location,
isManualRefresh: Boolean
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AirQualityResult = withContext(Dispatchers.IO) {
val cache = dao.getAirQualityForLocation(location.id)
val shouldReturnCache = shouldReturnAirQualityCache(cache, isManualRefresh)
val shouldReturnCache = shouldReturnAirQualityCache(cache, isManualRefresh, isForceRefresh)
when (shouldReturnCache) {
AirQualityResultType.RETURN_CACHE -> return@withContext AirQualityResult.Success(cache!!.toDomain())
@@ -0,0 +1,59 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu
import com.pranshulgg.weather_master_app.BuildConfig
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiForecastJson
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.AccuAqiJson
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json.AlertsAccuJson
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
interface AlertsAccuApi {
@GET("alerts/v1/{locationKey}")
suspend fun fetchAlerts(
@Path("locationKey") locationKey: String,
@Query("details") details: Boolean = true
): Response<List<AlertsAccuJson>>
companion object {
const val BASE_URL = "https://api.accuweather.com/"
fun create(): AlertsAccuApi {
val auth = Interceptor { chain ->
val original = chain.request()
val newUrl =
original.url.newBuilder().addQueryParameter("apikey", BuildConfig.ACCU_KEY)
.build()
val request = original.newBuilder().url(newUrl).build()
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(auth)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AlertsAccuApi::class.java)
}
}
}
@@ -0,0 +1,72 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResultType
import com.pranshulgg.weather_master_app.core.network.sources.weather.accu.AccuApi
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnAlertsCache
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toEntity
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.repository.AlertRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class AlertsAccuRepository @Inject constructor(
private val api: AlertsAccuApi,
private val dao: AlertsDao,
private val accuDao: AccuDao,
private val accuApi: AccuApi,
) : AlertRepository {
override suspend fun getAlerts(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AlertResult = withContext(Dispatchers.IO) {
val cache = dao.getAlertsForLocation(location.id)
val shouldReturnCache = shouldReturnAlertsCache(cache, isManualRefresh, isForceRefresh)
when (shouldReturnCache) {
AlertResultType.RETURN_CACHE -> return@withContext AlertResult.Success(cache.map { it!!.toDomain() })
else -> {}
}
return@withContext try {
val locationKey = accuDao.getCityKeyForLocation(location.id)?.toDomain()?.cityKey
?: accuApi.getLocationKey("${location.latitude},${location.longitude}").body()?.key
?: return@withContext AlertResult.Error(exception = AppException.Unknown())
val response = api.fetchAlerts(locationKey)
val body = response.body()
?: return@withContext AlertResult.Error(exception = AppException.Unknown())
val domain = body.map { it.toDomain(location.id) }
accuDao.insertCityKey(
AccuEntity(
locationId = location.id,
cityKey = locationKey
)
)
dao.insertAlerts(
domain.map { it.toEntity(location.id) },
location.id
)
AlertResult.Success(domain)
} catch (e: Exception) {
AlertResult.Error(exception = e, cacheAlerts = cache.map { it!!.toDomain() })
}
}
}
@@ -0,0 +1,24 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
data class AlertsAccuJson(
@SerializedName("Description") val event: AlertsAccuEventJson,
@SerializedName("AlarmLevel") val alarmLevel: String?,
@SerializedName("Source") val source: String?,
@SerializedName("Area") val area: List<AlertsAccuAreaJson>,
)
data class AlertsAccuEventJson(
@SerializedName("English") val english: String,
@SerializedName("Localized") val localized: String
)
data class AlertsAccuAreaJson(
@SerializedName("EpochStartTime") val epochStartTimeSeconds: Long,
@SerializedName("EpochEndTime") val epochEndTimeSeconds: Long,
@SerializedName("Text") val text: String,
)
@@ -0,0 +1,55 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi
import com.pranshulgg.weather_master_app.BuildConfig
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query
import java.util.concurrent.TimeUnit
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.json.AlertsWeatherApiJson
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Response
interface AlertsWeatherApi {
@GET("alerts.json")
suspend fun fetchAlerts(
@Query("q") query: String
): Response<AlertsWeatherApiJson>
companion object {
const val BASE_URL = "https://api.weatherapi.com/v1/"
fun create(): AlertsWeatherApi {
val auth = Interceptor { chain ->
val original = chain.request()
val newUrl =
original.url.newBuilder()
.addQueryParameter("key", BuildConfig.WEATHERAPI_KEY)
.build()
val request = original.newBuilder().url(newUrl).build()
chain.proceed(request)
}
val client = OkHttpClient.Builder()
.addInterceptor(auth)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AlertsWeatherApi::class.java)
}
}
}
@@ -0,0 +1,57 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResultType
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnAlertsCache
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.weatherapi.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toEntity
import com.pranshulgg.weather_master_app.data.repository.AlertRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class AlertsWeatherApiRepository @Inject constructor(
private val api: AlertsWeatherApi,
private val dao: AlertsDao
) : AlertRepository {
override suspend fun getAlerts(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AlertResult = withContext(Dispatchers.IO) {
val cache = dao.getAlertsForLocation(location.id)
val shouldReturnCache = shouldReturnAlertsCache(cache, isManualRefresh, isForceRefresh)
when (shouldReturnCache) {
AlertResultType.RETURN_CACHE -> return@withContext AlertResult.Success(cache.map { it!!.toDomain() })
else -> {}
}
return@withContext try {
val response = api.fetchAlerts("${location.latitude},${location.longitude}")
val body = response.body()
?: return@withContext AlertResult.Error(exception = AppException.Unknown())
val domain = body.toDomain(location.id)
dao.insertAlerts(
domain.map { it.toEntity(location.id) },
location.id
)
AlertResult.Success(domain)
} catch (e: Exception) {
AlertResult.Error(exception = e, cacheAlerts = cache.map { it!!.toDomain() })
}
}
}
@@ -0,0 +1,19 @@
package com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.json
data class AlertsWeatherApiJson(
val alerts: AlertsWeatherApiAlertJson
)
data class AlertsWeatherApiAlertJson(
val alert: List<AlertsWeatherApiAlertItemJson>
)
data class AlertsWeatherApiAlertItemJson(
val event: String,
val effective: String?,
val expires: String?,
val desc: String?,
val severity: String?,
val headline: String?
)
@@ -9,8 +9,10 @@ import com.pranshulgg.weather_master_app.core.network.sources.weather.bmkg.BmkgA
import com.pranshulgg.weather_master_app.core.network.sources.weather.bmkg.json.bundle.BmkgForecastBundle
import com.pranshulgg.weather_master_app.core.utils.weather.cache.isWeatherCacheSafe
import com.pranshulgg.weather_master_app.core.utils.weather.cache.shouldReturnWeatherCache
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherDao
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.accu.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.bmkg.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.weather.toCurrentWeatherEntity
@@ -27,7 +29,8 @@ import javax.inject.Inject
class AccuRepository @Inject constructor(
val dao: LocationsDao,
val weatherDao: WeatherDao,
val api: AccuApi
val api: AccuApi,
val accuDao: AccuDao,
) : WeatherRepository {
@@ -52,22 +55,22 @@ class AccuRepository @Inject constructor(
return@withContext try {
val locationKey = api.getLocationKey("${location.latitude},${location.longitude}")
val bodyLocation = locationKey.body()
val locationKey = accuDao.getCityKeyForLocation(location.id)?.cityKey
?: api.getLocationKey("${location.latitude},${location.longitude}").body()?.key
?: return@withContext WeatherResult.Error(exception = AppException.Unknown())
val current = api.fetchCurrent(bodyLocation.key)
val current = api.fetchCurrent(locationKey)
val bodyCurrent = current.body()
?: return@withContext WeatherResult.Error(exception = AppException.Unknown())
val hourly = api.fetchHourly(bodyLocation.key)
val hourly = api.fetchHourly(locationKey)
val bodyHourly = hourly.body()
?: return@withContext WeatherResult.Error(exception = AppException.Unknown())
val daily = api.fetchDaily(bodyLocation.key)
val daily = api.fetchDaily(locationKey)
val bodyDaily = daily.body()
?: return@withContext WeatherResult.Error(exception = AppException.Unknown())
@@ -80,6 +83,13 @@ class AccuRepository @Inject constructor(
val domain = final.toDomain(location)
accuDao.insertCityKey(
AccuEntity(
locationId = location.id,
cityKey = locationKey
)
)
weatherDao.insertWeather(
domain.current.toCurrentWeatherEntity(location.id),
domain.hourly.toHourlyWeatherEntity(location.id),
@@ -35,7 +35,7 @@ data class NwsGridPointDataQuantitativePrecipitationJson(
data class NwsGridPointDataQuantitativePrecipitationValuesJson(
val validTime: String,
val value: Double
val value: Double?
)
data class NwsGridPointDataSnowfallAmountJson(
@@ -44,7 +44,7 @@ data class NwsGridPointDataSnowfallAmountJson(
data class NwsGridPointDataSnowfallAmountValuesJson(
val validTime: String,
val value: Double
val value: Double?
)
data class NwsGridPointDataMaxTemperatureJson(
@@ -57,12 +57,12 @@ data class NwsGridPointDataMinTemperatureJson(
data class NwsGridPointDataMaxTemperatureValuesJson(
val validTime: String,
val value: Double
val value: Double?
)
data class NwsGridPointDataMinTemperatureValuesJson(
val validTime: String,
val value: Double
val value: Double?
)
data class NwsGridPointDataVisibilityJson(
@@ -71,5 +71,5 @@ data class NwsGridPointDataVisibilityJson(
data class NwsGridPointDataVisibilityValuesJson(
val validTime: String,
val value: Double
val value: Double?
)
@@ -82,6 +82,7 @@ sealed class SettingTile {
val danger: Boolean = false,
val selected: Boolean = false,
val trailing: (@Composable (() -> Unit))? = null,
val overline: (@Composable (() -> Unit))? = null,
) : SettingTile()
@@ -216,7 +217,8 @@ fun SettingSection(
danger = tile.danger,
itemBgColor = itemBgColor,
selected = tile.selected,
trailing = tile.trailing
trailing = tile.trailing,
overline = tile.overline
)
is SettingTile.SwitchTile -> SwitchTile(
@@ -24,7 +24,8 @@ fun ActionTile(
danger: Boolean = false,
itemBgColor: Color,
selected: Boolean = false,
trailing: @Composable (() -> Unit)? = null
trailing: @Composable (() -> Unit)? = null,
overline: @Composable (() -> Unit)? = null,
) {
Surface(
@@ -55,6 +56,7 @@ fun ActionTile(
)
}
},
overlineContent = overline,
trailingContent = trailing
)
}
@@ -13,14 +13,17 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navArgument
import com.pranshulgg.weather_master_app.feature.alerts.AlertsScreen
import com.pranshulgg.weather_master_app.feature.blocks.screens.SunMoonScreen
import com.pranshulgg.weather_master_app.feature.blocks.screens.airquality.AirQualityScreen
import com.pranshulgg.weather_master_app.feature.blocks.screens.humidity.HumidityScreen
@@ -45,6 +48,7 @@ import com.pranshulgg.weather_master_app.feature.settings.background.WorkerInfoS
import com.pranshulgg.weather_master_app.feature.settings.language.LanguageScreen
import com.pranshulgg.weather_master_app.feature.settings.sources.WeatherSourcesScreen
import com.pranshulgg.weather_master_app.feature.settings.units.UnitsScreen
import com.pranshulgg.weather_master_app.feature.shared.WeatherViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -67,13 +71,18 @@ fun AppNavHost(
) {
navigation(
route = "root",
// startDestination = NavRoutes.MAIN
startDestination = NavRoutes.editLocation("af673c89-72b7-4da9-865d-c26fdabeca07")
startDestination = NavRoutes.MAIN
// startDestination = NavRoutes.alerts("5e8c6c88-e85a-4e76-8d0b-5d1eb2906acd")
) {
composable(
NavRoutes.MAIN
) {
MainScreen(navController)
) { backStackEntry ->
val rootEntry = remember(backStackEntry) {
navController.getBackStackEntry("root")
}
val weatherViewModel: WeatherViewModel = hiltViewModel(rootEntry)
MainScreen(navController, weatherViewModel)
}
composable(
NavRoutes.SEARCH
@@ -122,6 +131,18 @@ fun AppNavHost(
DailyScreen(navController, index, locationId!!)
}
composable(
route = "${NavRoutes.ALERTS}/{locationId}",
arguments = listOf(
navArgument("locationId") {
type = NavType.StringType
}
)
) { backStackEntry ->
val locationId = backStackEntry.arguments?.getString("locationId")
AlertsScreen(navController, locationId!!)
}
composable(
NavRoutes.ABOUT
) {
@@ -164,7 +185,12 @@ fun AppNavHost(
val locationId = backStackEntry.arguments?.getString("locationId")
EditLocationScreen(navController, locationId!!)
val rootEntry = remember(backStackEntry) {
navController.getBackStackEntry("root")
}
val weatherViewModel: WeatherViewModel = hiltViewModel(rootEntry)
EditLocationScreen(navController, locationId!!, weatherViewModel)
}
composable(
route = "{block}/{index}/{locationId}",
@@ -47,6 +47,12 @@ object NavRoutes {
const val EDIT_LOCATION = "edit_location"
const val ALERTS = "alerts"
fun alerts(locationId: String): String {
return "$ALERTS/$locationId"
}
fun editLocation(locationId: String): String {
return "$EDIT_LOCATION/$locationId"
}
@@ -1,6 +1,7 @@
package com.pranshulgg.weather_master_app.core.utils.weather.cache
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQuality
import com.pranshulgg.weather_master_app.core.model.weather.WeatherResultType
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResultType
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.AUTO_REFRESH_MAX_MINUTES
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.MANUAL_REFRESH_MINUTES
@@ -20,9 +21,13 @@ fun isCurrentAirQualitySafe(airQuality: AirQuality?): Boolean {
fun shouldReturnAirQualityCache(
cache: AirQualityWithRelations?,
isManualRefresh: Boolean
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AirQualityResultType {
if (isForceRefresh) return AirQualityResultType.ERROR
if (cache == null || cache.current == null) {
return AirQualityResultType.ERROR
}
@@ -0,0 +1,38 @@
package com.pranshulgg.weather_master_app.core.utils.weather.cache
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResultType
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResultType
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.AUTO_REFRESH_MAX_MINUTES
import com.pranshulgg.weather_master_app.core.utils.weather.cache.CacheConfig.MANUAL_REFRESH_MINUTES
import com.pranshulgg.weather_master_app.data.local.entity.airquality.AirQualityWithRelations
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
import java.util.concurrent.TimeUnit
fun shouldReturnAlertsCache(
cache: List<AlertEntity?>,
isManualRefresh: Boolean,
isForceRefresh: Boolean
): AlertResultType {
if (isForceRefresh) return AlertResultType.ERROR
if (cache.isEmpty() || cache[0] == null) {
return AlertResultType.ERROR
}
val cacheMilli = cache[0]!!.lastUpdatedInMilli
val ageMillis = System.currentTimeMillis() - cacheMilli
val ageMinutes = TimeUnit.MILLISECONDS.toMinutes(ageMillis)
val tooEarly = isManualRefresh && ageMinutes < MANUAL_REFRESH_MINUTES
val maxAge = if (isManualRefresh) MANUAL_REFRESH_MINUTES else AUTO_REFRESH_MAX_MINUTES
if (tooEarly) return AlertResultType.RETURN_CACHE
val shouldReturnCache = ageMinutes < maxAge
return if (shouldReturnCache) AlertResultType.RETURN_CACHE else AlertResultType.ERROR
}
@@ -13,6 +13,7 @@ import com.pranshulgg.weather_master_app.core.prefs.AppPrefsState
import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId
import com.pranshulgg.weather_master_app.core.utils.locale.getCurrentAppLocale
import com.pranshulgg.weather_master_app.core.utils.weather.computing.computeDailyWeatherCondition
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findHourlyIndexForTime
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findMatchingHourly
import java.time.Instant
import java.time.format.DateTimeFormatter
@@ -33,8 +34,13 @@ fun computeDaySummary(
)
val currentIndex = findHourlyIndexForTime(
hourly.map { it.time },
weather.current.time
)
val (day, night) = hourly.partition { forecast ->
val (day, night) = hourly.drop(currentIndex).partition { forecast ->
// consider 6am to 5pm daytime
toHour(forecast.time, weather.location.timezone).toInt() in 6..17
@@ -158,4 +164,5 @@ private fun toHour(timeMilli: Long, zoneId: String): String {
.withZone(safeZoneId(zoneId))
return formatter.format(instant)
}
}
@@ -1,12 +1,9 @@
package com.pranshulgg.weather_master_app.core.utils.weather.computing.summary
import android.content.Context
import android.util.Log
import androidx.compose.ui.res.stringResource
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherUnits
import com.pranshulgg.weather_master_app.core.model.weather.TemperatureUnit
import com.pranshulgg.weather_master_app.core.prefs.AppPrefsState
import com.pranshulgg.weather_master_app.core.prefs.helper.PreferencesHelper
import com.pranshulgg.weather_master_app.core.utils.formatters.to12HourTimeString
import com.pranshulgg.weather_master_app.core.utils.formatters.to24HourTimeString
@@ -134,6 +131,7 @@ fun getHeadline(
val uvSentence = when {
peakUv.uv >= 10 ->
listOf(
context.getString(R.string.summary_uv_extreme_template_1, peakUvAt),
context.getString(R.string.summary_uv_extreme_template_2, peakUvAt),
context.getString(R.string.summary_uv_extreme_template_3, peakUvAt)
@@ -34,7 +34,8 @@ fun findMatchingHourly(
data: List<WeatherHourly>,
currentMilli: Long,
source: WeatherSource,
zoneId: String
zoneId: String,
alwaysReturn24Hrs: Boolean = false
): List<WeatherHourly> {
@@ -44,7 +45,12 @@ fun findMatchingHourly(
return emptyList()
}
val startDay = data[startIndex].time
return data.drop(maxOf(0, startIndex)).takeWhile { isSameDay(it.time, startDay, zoneId) }
return if (alwaysReturn24Hrs) {
data.drop(maxOf(0, startIndex)).take(24)
} else {
data.drop(maxOf(0, startIndex)).takeWhile { isSameDay(it.time, startDay, zoneId) }
}
}
@@ -0,0 +1,46 @@
package com.pranshulgg.weather_master_app.core.utils.weather.location
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
fun getLocationNameInShort(location: Location?): String {
if (location == null) {
return "••••"
}
val isCustomNameValid = !location.customName.isNullOrBlank()
return if (isCustomNameValid) {
location.customName
} else {
location.name
}
}
fun getFullLocationName(location: Location?): String {
if (location == null) {
return "••••"
}
val isCustomNameValid = !location.customName.isNullOrBlank()
val locationText = location.let {
buildString {
append(location.name)
if (location.country.isNotBlank()) {
append(", ")
}
if (location.state.isNotBlank()) {
append(location.state)
append(", ")
}
append(location.country)
}
}
return if (isCustomNameValid) {
location.customName
} else {
locationText
}
}
@@ -8,6 +8,8 @@ import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.dao.github.GithubDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherUnitsDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
@@ -17,6 +19,8 @@ import com.pranshulgg.weather_master_app.data.local.dao.weather.nws.NwsDao
import com.pranshulgg.weather_master_app.data.local.entity.weather.units.AppWeatherUnitsEntity
import com.pranshulgg.weather_master_app.data.local.entity.airquality.CurrentAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.airquality.HourlyAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
import com.pranshulgg.weather_master_app.data.local.entity.github.GithubEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.CurrentWeatherEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.DailyWeatherEntity
@@ -36,15 +40,20 @@ import com.pranshulgg.weather_master_app.data.local.entity.weather.nws.NwsGridPo
CurrentAirQualityEntity::class,
NwsGridPointsEntity::class,
GithubEntity::class,
HourlyAirQualityEntity::class
HourlyAirQualityEntity::class,
AccuEntity::class,
AlertEntity::class
],
version = 48,
version = 51,
autoMigrations = [
AutoMigration(from = 39, to = 40),
AutoMigration(from = 42, to = 43),
AutoMigration(from = 43, to = 44),
AutoMigration(from = 44, to = 45),
AutoMigration(from = 47, to = 48)
AutoMigration(from = 47, to = 48),
AutoMigration(from = 48, to = 49),
AutoMigration(from = 49, to = 50),
AutoMigration(from = 50, to = 51)
]
)
abstract class WeatherMasterDatabase : RoomDatabase() {
@@ -57,6 +66,10 @@ abstract class WeatherMasterDatabase : RoomDatabase() {
abstract fun nwsDao(): NwsDao
abstract fun githubDao(): GithubDao
abstract fun accuDao(): AccuDao
abstract fun alertsDao(): AlertsDao
companion object {
@Volatile
@@ -18,9 +18,9 @@ interface AirQualityDao {
hourlyAirQuality: List<HourlyAirQualityEntity>,
id: String
) {
insertCurrentAirQuality(currentAirQuality)
deleteCurrentAirQuality(id)
deleteHourlyAirQuality(id)
insertCurrentAirQuality(currentAirQuality)
insertHourlyAirQuality(hourlyAirQuality)
}
@@ -0,0 +1,22 @@
package com.pranshulgg.weather_master_app.data.local.dao.airquality.accu
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.nws.NwsGridPointsEntity
@Dao
interface AccuDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCityKey(entity: AccuEntity)
@Query("SELECT * FROM accu_locations WHERE locationId = :locationId")
suspend fun getCityKeyForLocation(locationId: String): AccuEntity?
@Query("DELETE FROM accu_locations WHERE locationId = :locationId")
suspend fun deleteCityKeyForLocation(locationId: String)
}
@@ -0,0 +1,35 @@
package com.pranshulgg.weather_master_app.data.local.dao.alerts
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.pranshulgg.weather_master_app.data.local.entity.airquality.CurrentAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AlertsDao {
@Transaction
suspend fun insertAlerts(alerts: List<AlertEntity>, locationId: String) {
deleteAlertsForLocation(locationId)
insertAlertList(alerts)
}
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
suspend fun insertAlertList(alerts: List<AlertEntity>)
@Query("SELECT * FROM alerts WHERE locationId = :locationId")
suspend fun getAlertsForLocation(locationId: String): List<AlertEntity?>
@Query("DELETE FROM alerts WHERE locationId = :locationId")
suspend fun deleteAlertsForLocation(locationId: String)
@Query("SELECT * FROM alerts")
fun getAllLocationsAlerts(): Flow<List<AlertEntity?>>
}
@@ -6,7 +6,9 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
import com.pranshulgg.weather_master_app.data.local.entity.weather.WeatherWithRelations
import kotlinx.coroutines.flow.Flow
@@ -79,4 +81,12 @@ interface LocationsDao {
@Query("UPDATE weather_locations SET airQualitySource = :source WHERE id = :id")
suspend fun updateAirQualitySourceForLocation(id: String, source: AirQualitySource)
@Query("UPDATE weather_locations SET alertSource = :source WHERE id = :id")
suspend fun updateAlertSourceForLocation(id: String, source: AlertSource)
@Query("UPDATE weather_locations SET customName = :name WHERE id = :id")
suspend fun updateLocationCustomName(id: String, name: String?)
}
@@ -0,0 +1,24 @@
package com.pranshulgg.weather_master_app.data.local.entity.airquality.accu
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
@Entity(
tableName = "accu_locations",
foreignKeys = [
ForeignKey(
entity = WeatherLocationEntity::class,
parentColumns = ["id"],
childColumns = ["locationId"],
onDelete = ForeignKey.Companion.CASCADE
)
],
indices = [Index("locationId")],
primaryKeys = ["locationId"]
)
data class AccuEntity(
val locationId: String,
val cityKey: String
)
@@ -0,0 +1,34 @@
package com.pranshulgg.weather_master_app.data.local.entity.alerts
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
@Entity(
tableName = "alerts",
foreignKeys = [
ForeignKey(
entity = WeatherLocationEntity::class,
parentColumns = ["id"],
childColumns = ["locationId"],
onDelete = ForeignKey.Companion.CASCADE
)
],
indices = [Index("locationId")],
)
data class AlertEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val locationId: String,
val event: String,
val severity: AlertSeverity?,
val effective: Long?,
val expires: Long?,
val description: String,
val source: String?,
val lastUpdatedInMilli: Long
)
@@ -0,0 +1,32 @@
package com.pranshulgg.weather_master_app.data.local.mapper.airquality
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityCurrent
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityHourly
import com.pranshulgg.weather_master_app.data.local.entity.airquality.CurrentAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.airquality.HourlyAirQualityEntity
fun AirQualityCurrent.toEntity(locationId: String): CurrentAirQualityEntity =
CurrentAirQualityEntity(
locationId = locationId,
usAqi = usAqi,
pm10 = pm10,
pm25 = pm25,
carbonMonoxide = carbonMonoxide,
nitrogenDioxide = nitrogenDioxide,
sulphurDioxide = sulphurDioxide,
ozone = ozone,
lastUpdatedInMilli = lastUpdatedInMilli
)
fun AirQualityHourly.toEntity(locationId: String): HourlyAirQualityEntity =
HourlyAirQualityEntity(
locationId = locationId,
pm10 = pm10,
pm25 = pm25,
carbonMonoxide = carbonMonoxide,
nitrogenDioxide = nitrogenDioxide,
sulphurDioxide = sulphurDioxide,
ozone = ozone,
time = time
)
@@ -0,0 +1,41 @@
package com.pranshulgg.weather_master_app.data.local.mapper.airquality.sources.accu
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQuality
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityCurrent
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityHourly
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.json.bundle.AccuAqiJsonBundle
fun AccuAqiJsonBundle.toDomain(): AirQuality {
val current = this.current.data.pollutants.associate { it.type to it.concentration.value }
val hourly = this.forecast.data
return AirQuality(
current = AirQualityCurrent(
usAqi = null,
pm10 = current["PM10"],
pm25 = current["PM2_5"],
carbonMonoxide = current["CO"],
nitrogenDioxide = current["NO2"],
sulphurDioxide = current["SO2"],
ozone = current["O3"],
lastUpdatedInMilli = System.currentTimeMillis()
),
hourly = List(hourly.size) { index ->
val pollutants =
hourly[index].pollutants.associate { it.type to it.concentration.value }
AirQualityHourly(
time = hourly[index].timeSeconds,
pm10 = pollutants["PM10"],
pm25 = pollutants["PM2_5"],
carbonMonoxide = pollutants["CO"],
nitrogenDioxide = pollutants["NO2"],
sulphurDioxide = pollutants["SO2"],
ozone = pollutants["O3"]
)
}
)
}
@@ -1,11 +1,10 @@
package com.pranshulgg.weather_master_app.data.local.mapper.airquality
package com.pranshulgg.weather_master_app.data.local.mapper.airquality.sources.openmeteo
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQuality
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityCurrent
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQualityHourly
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiDto
import com.pranshulgg.weather_master_app.data.local.entity.airquality.CurrentAirQualityEntity
import com.pranshulgg.weather_master_app.data.local.entity.airquality.HourlyAirQualityEntity
fun OpenMeteoAqiDto.toDomain(): AirQuality =
AirQuality(
@@ -30,30 +29,4 @@ fun OpenMeteoAqiDto.toDomain(): AirQuality =
ozone = hourly.ozone[it]
)
}
)
fun AirQualityCurrent.toEntity(locationId: String): CurrentAirQualityEntity =
CurrentAirQualityEntity(
locationId = locationId,
usAqi = usAqi,
pm10 = pm10,
pm25 = pm25,
carbonMonoxide = carbonMonoxide,
nitrogenDioxide = nitrogenDioxide,
sulphurDioxide = sulphurDioxide,
ozone = ozone,
lastUpdatedInMilli = lastUpdatedInMilli
)
fun AirQualityHourly.toEntity(locationId: String): HourlyAirQualityEntity =
HourlyAirQualityEntity(
locationId = locationId,
pm10 = pm10,
pm25 = pm25,
carbonMonoxide = carbonMonoxide,
nitrogenDioxide = nitrogenDioxide,
sulphurDioxide = sulphurDioxide,
ozone = ozone,
time = time
)
@@ -0,0 +1,31 @@
package com.pranshulgg.weather_master_app.data.local.mapper.alerts
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.data.local.entity.alerts.AlertEntity
fun Alert.toEntity(locationId: String): AlertEntity {
return AlertEntity(
locationId = locationId,
event = event,
severity = severity,
effective = effective,
expires = expires,
description = description,
source = source,
lastUpdatedInMilli = lastUpdatedInMilli
)
}
fun AlertEntity.toDomain(): Alert {
return Alert(
locationId = locationId,
event = event,
severity = severity,
effective = effective,
expires = expires,
description = description,
source = source,
lastUpdatedInMilli = lastUpdatedInMilli
)
}
@@ -0,0 +1,29 @@
package com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.accu
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.json.AlertsAccuJson
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.secondsToMilliseconds
fun AlertsAccuJson.toDomain(locationId: String): Alert {
return Alert(
locationId = locationId,
event = event.english,
severity = getSeverity(alarmLevel),
effective = area[0].epochStartTimeSeconds.secondsToMilliseconds(),
expires = area[0].epochEndTimeSeconds.secondsToMilliseconds(),
description = area[0].text,
source = source,
lastUpdatedInMilli = System.currentTimeMillis()
)
}
private fun getSeverity(severity: String?): AlertSeverity {
return when (severity) {
"Red" -> AlertSeverity.CRITICAL
"Orange" -> AlertSeverity.HIGH
"Yellow" -> AlertSeverity.MODERATE
else -> AlertSeverity.UNKNOWN
}
}
@@ -0,0 +1,35 @@
package com.pranshulgg.weather_master_app.data.local.mapper.alerts.sources.weatherapi
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.json.AlertsWeatherApiJson
import com.pranshulgg.weather_master_app.core.utils.extensions.DateTimeExtensions.iso8601TimestampToMilliseconds
fun AlertsWeatherApiJson.toDomain(locationId: String): List<Alert> {
return this.alerts.alert.map {
val desc = if (it.desc.isNullOrBlank()) it.headline else it.desc
Alert(
locationId = locationId,
event = it.event,
severity = getSeverity(it.severity),
effective = it.effective?.iso8601TimestampToMilliseconds(),
expires = it.expires?.iso8601TimestampToMilliseconds(),
description = desc ?: "",
source = "WeatherApi.com",
lastUpdatedInMilli = System.currentTimeMillis()
)
}
}
private fun getSeverity(severity: String?): AlertSeverity {
return when (severity) {
"Extreme" -> AlertSeverity.CRITICAL
"Severe" -> AlertSeverity.HIGH
"Moderate" -> AlertSeverity.MODERATE
"Minor" -> AlertSeverity.LOW
else -> AlertSeverity.UNKNOWN
}
}
@@ -0,0 +1,23 @@
package com.pranshulgg.weather_master_app.data.local.mapper.weather.sources.accu
import com.pranshulgg.weather_master_app.core.model.weather.airquality.accu.AccuCityKey
import com.pranshulgg.weather_master_app.data.local.entity.airquality.accu.AccuEntity
// ---------------------------- ENTITY TO DOMAIN ----------------------------
fun AccuEntity.toDomain(): AccuCityKey {
return AccuCityKey(
locationId = locationId,
cityKey = cityKey
)
}
// ---------------------------- DOMAIN TO ENTITY ----------------------------
fun AccuCityKey.toEntity(): AccuEntity {
return AccuEntity(
locationId = locationId,
cityKey = cityKey
)
}
@@ -221,7 +221,7 @@ fun NwsWeatherJsonBundle.toDomain(location: Location): Weather {
dawn = sunTimings[index].dawn ?: 0L,
dusk = sunTimings[index].dusk ?: 0L,
pressureMsl = null,
visibility = minVisibility.roundToInt(),
visibility = minVisibility?.roundToInt(),
humidity = avgHumidity,
dewPoint = avgDewPoint
)
@@ -314,7 +314,7 @@ private fun <T> matchingMinMaxTemperature(
private fun getRainSum(
data: Map<Long, Double>,
data: Map<Long, Double?>,
time: Long
): Double {
@@ -330,7 +330,7 @@ private fun getRainSum(
}
private fun getSnowfallSum(
data: Map<Long, Double>,
data: Map<Long, Double?>,
time: Long
): Double {
@@ -346,16 +346,16 @@ private fun getSnowfallSum(
}
private fun getMinVisibility(
data: Map<Long, Double>,
data: Map<Long, Double?>,
time: Long
): Double {
): Double? {
val startIndex = data.toList().indexOfFirst { it.first >= time }.takeIf { it != -1 } ?: 0
val data = data.toList().drop(maxOf(0, startIndex))
.take(WeatherSource.NWS.hourlyAggregationLimitHours)
val minVisibility = data.minOf { it.second ?: 0.0 }
val minVisibility = data.mapNotNull { it.second }.minOfOrNull { it }
return minVisibility
}
@@ -0,0 +1,23 @@
package com.pranshulgg.weather_master_app.data.provider
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.network.sources.airquality.accu.AccuAqiRepository
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiRepository
import com.pranshulgg.weather_master_app.data.repository.AirQualityRepository
import javax.inject.Inject
class AirQualityRepositoryProvider @Inject constructor(
private val openMeteoAqiRepository: OpenMeteoAqiRepository,
private val accuAqiRepository: AccuAqiRepository
) {
fun getRepository(source: AirQualitySource): AirQualityRepository? {
return when (source) {
AirQualitySource.ACCU_WEATHER -> accuAqiRepository
AirQualitySource.OPEN_METEO -> openMeteoAqiRepository
AirQualitySource.NONE -> null
}
}
}
@@ -0,0 +1,23 @@
package com.pranshulgg.weather_master_app.data.provider
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.network.sources.alerts.accu.AlertsAccuRepository
import com.pranshulgg.weather_master_app.core.network.sources.alerts.weatherapi.AlertsWeatherApiRepository
import com.pranshulgg.weather_master_app.data.repository.AlertRepository
import javax.inject.Inject
class AlertsRepositoryProvider @Inject constructor(
private val alertsAccuRepository: AlertsAccuRepository,
private val alertsWeatherApiRepository: AlertsWeatherApiRepository
) {
fun getRepository(source: AlertSource): AlertRepository? {
return when (source) {
AlertSource.ACCU_WEATHER -> alertsAccuRepository
AlertSource.WEATHER_API -> alertsWeatherApiRepository
AlertSource.NONE -> null
}
}
}
@@ -0,0 +1,15 @@
package com.pranshulgg.weather_master_app.data.repository
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResult
interface AirQualityRepository {
suspend fun getAirQuality(
location: Location,
isManualRefresh: Boolean = false,
isForceRefresh: Boolean
): AirQualityResult
}
@@ -0,0 +1,12 @@
package com.pranshulgg.weather_master_app.data.repository
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult
interface AlertRepository {
suspend fun getAlerts(
location: Location,
isManualRefresh: Boolean = false,
isForceRefresh: Boolean
): AlertResult
}
@@ -0,0 +1,21 @@
package com.pranshulgg.weather_master_app.data.repository
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toDomain
import jakarta.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class AlertsRepository @Inject constructor(
private val dao: AlertsDao,
) {
fun getAllLocationsAlerts(): Flow<List<Alert?>> {
return dao.getAllLocationsAlerts().map { list -> list.map { it?.toDomain() } }
}
suspend fun getAlertsForLocation(locationId: String): List<Alert?> {
return dao.getAlertsForLocation(locationId).map { it?.toDomain() }
}
}
@@ -1,18 +1,20 @@
package com.pranshulgg.weather_master_app.data.repository
import android.content.Context
import android.util.Log
import androidx.room.Transaction
import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQuality
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.domain.weather.Weather
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.network.sources.address.nominatim.json.NominatimRepository
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.mapper.airquality.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.alerts.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.locations.toDomain
import com.pranshulgg.weather_master_app.data.local.mapper.locations.toEntity
import com.pranshulgg.weather_master_app.data.local.mapper.weather.toDomain
@@ -23,11 +25,9 @@ import com.pranshulgg.weather_master_app.feature.intro.toDomain
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.time.ZoneId
import kotlin.coroutines.resumeWithException
@@ -79,10 +79,17 @@ class LocationsRepository @Inject constructor(
dao.updateAirQualitySourceForLocation(id, source)
}
suspend fun updateAlertSourceForLocation(id: String, source: AlertSource) {
dao.updateAlertSourceForLocation(id, source)
}
suspend fun getLocationForId(id: String): Location {
return dao.getLocationForId(id).toDomain()
}
suspend fun updateLocationCustomName(id: String, name: String?) {
dao.updateLocationCustomName(id, name)
}
@Transaction
suspend fun saveLocation(location: Location) {
@@ -204,6 +211,8 @@ class LocationsRepository @Inject constructor(
return dao.getAllLocationsCurrentWeather()
.map { list -> list.map { it.toDomain() } }
}
}
@@ -1,6 +1,11 @@
package com.pranshulgg.weather_master_app.data.repository
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.data.local.dao.airquality.AirQualityDao
import com.pranshulgg.weather_master_app.data.local.dao.airquality.accu.AccuDao
import com.pranshulgg.weather_master_app.data.local.dao.alerts.AlertsDao
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
import com.pranshulgg.weather_master_app.data.local.dao.weather.nws.NwsDao
import jakarta.inject.Inject
@@ -8,7 +13,10 @@ import jakarta.inject.Inject
class WeatherDataReconcilerRepository @Inject constructor(
private val nwsDao: NwsDao,
private val locationDao: LocationsDao
private val locationDao: LocationsDao,
private val accuDao: AccuDao,
private val airQualityDao: AirQualityDao,
private val alertsDao: AlertsDao
) {
/**
@@ -17,14 +25,56 @@ class WeatherDataReconcilerRepository @Inject constructor(
* NWS might have saved grid points
* which are important to be removed from the DB so they don't end up stale
*/
suspend fun cleanUpStaleData(previousSource: WeatherSource, locationId: String) {
suspend fun cleanUpStaleData(
previousSource: WeatherSource,
locationId: String,
airQualitySource: AirQualitySource,
currentAlertSource: AlertSource
) {
when (previousSource) {
WeatherSource.NWS -> cleanNws(locationId)
WeatherSource.NWS -> nwsDao.deleteGridPointsForLocation(locationId)
WeatherSource.ACCU_WEATHER -> cleanAccuWeather(
locationId,
airQualitySource,
currentAlertSource
)
else -> {}
}
}
suspend fun cleanNws(locationId: String) {
nwsDao.deleteGridPointsForLocation(locationId)
suspend fun cleanAccuWeather(
locationId: String, airQualitySource: AirQualitySource,
currentAlertSource: AlertSource
) {
if (airQualitySource != AirQualitySource.ACCU_WEATHER || currentAlertSource != AlertSource.ACCU_WEATHER) {
accuDao.deleteCityKeyForLocation(locationId)
}
}
suspend fun cleanUpStaleAirQualityData(
locationId: String,
currentWeatherSource: WeatherSource,
currentAlertSource: AlertSource
) {
airQualityDao.deleteCurrentAirQuality(locationId)
airQualityDao.deleteHourlyAirQuality(locationId)
if (currentWeatherSource != WeatherSource.ACCU_WEATHER || currentAlertSource != AlertSource.ACCU_WEATHER) {
accuDao.deleteCityKeyForLocation(locationId)
}
}
suspend fun cleanUpStaleAlertsData(
locationId: String,
currentWeatherSource: WeatherSource,
airQualitySource: AirQualitySource,
) {
alertsDao.deleteAlertsForLocation(locationId)
if (currentWeatherSource != WeatherSource.ACCU_WEATHER || airQualitySource != AirQualitySource.ACCU_WEATHER) {
accuDao.deleteCityKeyForLocation(locationId)
}
}
}
@@ -0,0 +1,97 @@
package com.pranshulgg.weather_master_app.feature.alerts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
import com.pranshulgg.weather_master_app.core.prefs.LocalAppPrefs
import com.pranshulgg.weather_master_app.core.ui.components.Gap
import com.pranshulgg.weather_master_app.core.ui.components.LargeTopBarScaffold
import com.pranshulgg.weather_master_app.core.ui.components.NavigateUpBtn
import com.pranshulgg.weather_master_app.core.ui.components.SettingSection
import com.pranshulgg.weather_master_app.core.ui.components.SettingTile
import com.pranshulgg.weather_master_app.core.ui.theme.ShapeRadius
import com.pranshulgg.weather_master_app.feature.alerts.components.AlertCard
import com.pranshulgg.weather_master_app.feature.settings.SettingsScreen
data class AlertsScreenUiState(
val alerts: List<Alert?> = emptyList(),
val location: Location? = null
)
@Composable
fun AlertsScreen(navController: NavController, locationId: String) {
val viewModel: AlertsScreenViewModel = hiltViewModel()
val uiState = viewModel.uiState.value
val prefs = LocalAppPrefs.current
LaunchedEffect(Unit) {
viewModel.getAlertsForLocation(locationId)
viewModel.getLocation(locationId)
}
LargeTopBarScaffold(
title = "Alerts",
navigationIcon = { NavigateUpBtn(navController) },
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = paddingValues.calculateTopPadding(), start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
uiState.alerts.filterNotNull().forEachIndexed { index, it ->
val isFirst = index == 0
val isLast = index == uiState.alerts.lastIndex
val isOnly = uiState.alerts.size == 1
val shape = when {
isOnly -> RoundedCornerShape(ShapeRadius.Large)
isFirst -> RoundedCornerShape(
topStart = ShapeRadius.Large,
topEnd = ShapeRadius.Large,
bottomStart = ShapeRadius.ExtraSmall,
bottomEnd = ShapeRadius.ExtraSmall
)
isLast -> RoundedCornerShape(
topStart = ShapeRadius.ExtraSmall,
topEnd = ShapeRadius.ExtraSmall,
bottomStart = ShapeRadius.Large,
bottomEnd = ShapeRadius.Large
)
else -> RoundedCornerShape(ShapeRadius.ExtraSmall)
}
AlertCard(it, prefs, uiState.location!!.timezone, shape)
}
Gap(WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + 30.dp)
}
}
}
@@ -0,0 +1,36 @@
package com.pranshulgg.weather_master_app.feature.alerts
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pranshulgg.weather_master_app.data.repository.AlertsRepository
import com.pranshulgg.weather_master_app.data.repository.LocationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AlertsScreenViewModel @Inject constructor(
val alertsRepository: AlertsRepository,
val locationRepository: LocationsRepository
) : ViewModel() {
private var _uiState = mutableStateOf(AlertsScreenUiState())
val uiState: State<AlertsScreenUiState> = _uiState
fun getAlertsForLocation(locationId: String) {
viewModelScope.launch {
val alerts = alertsRepository.getAlertsForLocation(locationId)
_uiState.value = _uiState.value.copy(alerts = alerts)
}
}
fun getLocation(locationId: String) {
viewModelScope.launch {
val location = locationRepository.getLocationForId(locationId)
_uiState.value = _uiState.value.copy(location = location)
}
}
}
@@ -0,0 +1,132 @@
package com.pranshulgg.weather_master_app.feature.alerts.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.prefs.AppPrefsState
import com.pranshulgg.weather_master_app.core.ui.components.Gap
import com.pranshulgg.weather_master_app.core.ui.components.Symbol
import com.pranshulgg.weather_master_app.core.ui.theme.ShadowElevation
import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId
import java.time.Instant
import java.time.format.DateTimeFormatter
@Composable
fun AlertCard(alert: Alert, prefs: AppPrefsState, zoneId: String, shape: Shape) {
val pattern = if (prefs.is24HrTimeFormat) "MMM dd, HH:mm" else "MMM dd, hh:mm a"
val formatter: (Long?) -> String? = {
it?.let {
val formatter = DateTimeFormatter.ofPattern(pattern)
val instant = Instant.ofEpochMilli(it)
val dateTime = instant.atZone(safeZoneId(zoneId)).toLocalDateTime()
formatter.format(dateTime)
} ?: "Unknown"
}
Surface(
color = MaterialTheme.colorScheme.surfaceBright,
shape = shape,
shadowElevation = ShadowElevation.level2
) {
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
5.dp,
alignment = Alignment.CenterHorizontally
),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
Column() {
Text(
alert.event,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Gap(6.dp)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalArrangement = Arrangement.spacedBy(
5.dp,
alignment = Alignment.CenterVertically
)
) {
Chip(
stringResource(alert.severity?.label ?: AlertSeverity.UNKNOWN.label),
alert.severity?.color ?: AlertSeverity.UNKNOWN.color,
alert.severity?.contentColor ?: AlertSeverity.UNKNOWN.contentColor
)
Chip(
"Effective ${formatter(alert.effective)}",
MaterialTheme.colorScheme.tertiaryContainer,
MaterialTheme.colorScheme.onTertiaryContainer
)
Chip(
"Expires ${formatter(alert.expires)}",
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Gap(12.dp)
HorizontalDivider()
Gap(12.dp)
Text(
alert.description,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp)
)
Gap(12.dp)
HorizontalDivider()
Gap(12.dp)
Text(
"Source ${alert.source}",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
}
@Composable
private fun Chip(text: String, color: Color, textColor: Color) {
Surface(
color = color,
shape = CircleShape,
) {
Text(
text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelLarge,
color = textColor
)
}
}
@@ -97,7 +97,7 @@ fun VisibilityHourlyCard(
val percentage = ((item.visibility!!.minus(visibilityMin))
.div((visibilityMax - visibilityMin))).times(100.0)
val barHeight = max((percentage.div(100.0)).times(140.0), 5.0)
val barHeight = max((percentage.div(100.0)).times(140.0), 5.0).coerceAtMost(140.0)
val visibilityKm =
@@ -155,4 +155,4 @@ fun VisibilityHourlyCard(
}
}
}
}
}
@@ -1,8 +1,6 @@
package com.pranshulgg.weather_master_app.feature.editlocation
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -38,15 +37,16 @@ import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.model.sources.getWeatherSourcesForCountry
import com.pranshulgg.weather_master_app.core.model.sources.getWeatherSourcesGlobal
import com.pranshulgg.weather_master_app.core.ui.components.Gap
import com.pranshulgg.weather_master_app.core.ui.components.LargeTopBarScaffold
import com.pranshulgg.weather_master_app.core.ui.components.NavigateUpBtn
import com.pranshulgg.weather_master_app.core.ui.components.SettingSection
import com.pranshulgg.weather_master_app.core.ui.components.SettingTile
import com.pranshulgg.weather_master_app.core.ui.components.Symbol
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.feature.editlocation.ui.EditLocationBottomSheet
import com.pranshulgg.weather_master_app.feature.editlocation.ui.EditLocationScreenDialogs
import com.pranshulgg.weather_master_app.feature.shared.WeatherViewModel
import com.pranshulgg.weather_master_app.feature.shared.ui.SharedBottomSheet
@@ -58,13 +58,15 @@ data class EditLocationScreenUiState(
val selectedAirQualitySource: AirQualitySource? = null,
val isAlertSourcesSheetOpen: Boolean = false,
val isAirQualitySourcesSheetOpen: Boolean = false,
val isEditLocationNameSheetOpen: Boolean = false
val isEditLocationNameSheetOpen: Boolean = false,
val isConfirmationDialogOpen: Boolean = false
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun EditLocationScreen(
navController: NavController, id: String
navController: NavController, id: String,
weatherViewModel: WeatherViewModel
) {
val viewModel: EditLocationViewModel = hiltViewModel()
@@ -76,7 +78,6 @@ fun EditLocationScreen(
if (uiState.location == null) return
val btnSize = ButtonDefaults.MediumContainerHeight
val sheetState = rememberBottomSheetState(
initialValue = SheetValue.Hidden,
@@ -125,11 +126,12 @@ fun EditLocationScreen(
viewModel.showEditLocationNameSheet()
},
trailing = {
IconButton(onClick = { currentLocationName = locationName }) {
IconButton(onClick = { currentLocationName = locationText }) {
Symbol(R.drawable.refresh_24px)
}
},
colorDesc = colorDesc
colorDesc = colorDesc,
overline = { Text("Requires restart") }
)
)
)
@@ -174,27 +176,47 @@ fun EditLocationScreen(
modifier = Modifier.padding(top = 10.dp, start = 16.dp, end = 16.dp)
)
Gap(26.dp)
Button(
modifier = Modifier
.heightIn(btnSize)
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = { /* Handle click */ },
shapes = ButtonDefaults.shapes(),
contentPadding = ButtonDefaults.contentPaddingFor(btnSize),
) {
Symbol(
R.drawable.check_24px,
color = MaterialTheme.colorScheme.onPrimaryContainer,
size = ButtonDefaults.iconSizeFor(btnSize)
)
Gap(horizontal = ButtonDefaults.iconSpacingFor(btnSize))
Text(
stringResource(R.string.action_save_changes),
style = ButtonDefaults.textStyleFor(btnSize),
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
ButtonWithIcon(
onClick = {
viewModel.saveLocationName(
if (currentLocationName.trim() == locationText.trim()) null else currentLocationName.trim(),
uiState.location.id
)
navController.popBackStack()
weatherViewModel.handleSourceChangeForWeather(
uiState.location,
uiState.selectedWeatherSource ?: uiState.location.source,
uiState.selectedAirQualitySource ?: uiState.location.airQualitySource,
uiState.selectedAlertSource ?: uiState.location.alertSource
)
},
text = stringResource(R.string.action_save_changes),
icon = R.drawable.check_24px
)
Gap(8.dp)
ButtonWithIcon(
onClick = {
viewModel.updateDefaultLocation(uiState.location.id)
navController.popBackStack()
},
text = stringResource(R.string.action_set_default),
icon = R.drawable.home_pin_24px,
)
Gap(8.dp)
ButtonWithIcon(
onClick = {
if (uiState.location.isDefault) {
SnackbarManager.show(R.string.error_delete_default_location)
return@ButtonWithIcon
}
viewModel.showConfirmationDialog()
},
text = stringResource(R.string.action_delete),
icon = R.drawable.delete_24px,
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
// WEATHER SOURCES SHEET
SharedBottomSheet.WeatherSourcesForLocationSheet(
@@ -248,9 +270,58 @@ fun EditLocationScreen(
value = currentLocationName,
)
Gap(WindowInsets.systemBars.asPaddingValues().calculateBottomPadding())
// CONFIRMATION DIALOG
EditLocationScreenDialogs.EditLocationScreenConfirmationDialog(
viewModel,
onConfirm = {
weatherViewModel.deleteLocation(uiState.location.id)
viewModel.hideConfirmationDialog()
navController.popBackStack()
}
)
}
Gap(WindowInsets.systemBars.asPaddingValues().calculateBottomPadding())
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ButtonWithIcon(
onClick: () -> Unit,
text: String,
icon: Int,
containerColor: Color = MaterialTheme.colorScheme.primary,
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
enabled: Boolean = true
) {
val btnSize = ButtonDefaults.MediumContainerHeight
Button(
colors = ButtonDefaults.buttonColors(
containerColor = containerColor
),
modifier = Modifier
.heightIn(btnSize)
.fillMaxWidth()
.padding(horizontal = 16.dp),
onClick = onClick,
enabled = enabled,
shapes = ButtonDefaults.shapes(),
contentPadding = ButtonDefaults.contentPaddingFor(btnSize),
) {
Symbol(
icon,
color = contentColor,
size = ButtonDefaults.iconSizeFor(btnSize)
)
Gap(horizontal = ButtonDefaults.iconSpacingFor(btnSize))
Text(
text,
style = ButtonDefaults.textStyleFor(btnSize),
color = contentColor
)
}
}
@@ -31,6 +31,12 @@ class EditLocationViewModel @Inject constructor(
}
fun saveLocationName(name: String?, id: String) {
viewModelScope.launch {
locationsRepo.updateLocationCustomName(id, name)
}
}
fun showWeatherSourcesForLocationSheet() {
_uiState.value = _uiState.value.copy(isWeatherSourcesForLocationSheetOpen = true)
}
@@ -74,4 +80,18 @@ class EditLocationViewModel @Inject constructor(
fun hideEditLocationNameSheet() {
_uiState.value = _uiState.value.copy(isEditLocationNameSheetOpen = false)
}
fun showConfirmationDialog() {
_uiState.value = _uiState.value.copy(isConfirmationDialogOpen = true)
}
fun hideConfirmationDialog() {
_uiState.value = _uiState.value.copy(isConfirmationDialogOpen = false)
}
fun updateDefaultLocation(id: String) {
viewModelScope.launch {
locationsRepo.updateDefaultLocation(id)
}
}
}
@@ -0,0 +1,25 @@
package com.pranshulgg.weather_master_app.feature.editlocation.ui
import androidx.compose.runtime.Composable
import com.pranshulgg.weather_master_app.core.ui.components.TextAlertDialog
import com.pranshulgg.weather_master_app.feature.editlocation.EditLocationViewModel
import com.pranshulgg.weather_master_app.feature.locations.LocationsScreenViewModel
import com.pranshulgg.weather_master_app.feature.shared.WeatherViewModel
object EditLocationScreenDialogs {
@Composable
fun EditLocationScreenConfirmationDialog(
viewModel: EditLocationViewModel,
onConfirm: () -> Unit
) {
val uiState = viewModel.uiState.value
TextAlertDialog(
show = uiState.isConfirmationDialogOpen,
onConfirm = onConfirm,
onDismiss = viewModel::hideConfirmationDialog,
title = "Delete Location",
message = "Are you sure you want to delete this location?"
)
}
}
@@ -14,7 +14,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class IntroScreenViewModel @Inject constructor(
val locationsRepo: LocationsRepository,
@ApplicationContext private val context: Context,
@@ -62,11 +62,11 @@ fun LocationsScreen(
locations: List<Location>,
activeLocation: Location?,
onLocationSelect: (Location) -> Unit,
isTabletLike: Boolean = false
isTabletLike: Boolean = false,
weatherViewModel: WeatherViewModel,
) {
val viewModel: LocationsScreenViewModel = hiltViewModel()
val weatherViewModel: WeatherViewModel = hiltViewModel()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val uiState = viewModel.uiState
@@ -74,6 +74,8 @@ fun LocationsScreen(
initialValue = emptyList()
)
val alertsForLocations by viewModel.allLocationsAlerts.collectAsStateWithLifecycle(initialValue = emptyList())
var backgroundLocationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
var locationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
@@ -110,7 +112,7 @@ fun LocationsScreen(
floatingActionButton = {
FloatingButton(navController)
},
floatingActionButtonPosition = FabPosition.Center
floatingActionButtonPosition = FabPosition.Center,
) { paddingValues ->
Box(
modifier = Modifier
@@ -128,14 +130,17 @@ fun LocationsScreen(
activeLocation = activeLocation,
weatherForLocations,
onAddCurrentLocation = { locationPermissionInfoDialogOpen = true },
uiState.value.isDeviceLocationLoading
uiState.value.isDeviceLocationLoading,
alertsForLocations
)
}
}
LocationScreenConfirmationDialog(weatherViewModel, viewModel)
LocationScreenSheet(viewModel, sheetState)
LocationScreenSheet(viewModel, sheetState, onEdit = {
navController.navigate(NavRoutes.editLocation(uiState.value.longClickedLocation!!.id))
})
SharedDialogs.DeviceBackgroundLocationPermissionInfoDialog(
show = backgroundLocationPermissionInfoDialogOpen,
@@ -185,6 +190,13 @@ private fun TopBar(onBack: () -> Unit, isTabletLike: Boolean) {
}
}
}
},
actions = {
IconButton(onClick = {
SnackbarManager.show(R.string.location_long_press_info)
}, shapes = IconButtonDefaults.shapes()) {
Symbol(R.drawable.info_24px)
}
}
)
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.WeatherCondition
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.domain.weather.Weather
@@ -46,10 +47,12 @@ fun LocationsScreenContent(
activeLocation: Location? = null,
weatherForLocations: List<Weather> = emptyList(),
onAddCurrentLocation: () -> Unit,
isDeviceLocationLoading: Boolean
isDeviceLocationLoading: Boolean,
alerts: List<Alert?>
) {
val weatherMap = weatherForLocations.associateBy { it.location.id }
val alertMap = alerts.groupBy { it?.locationId }
val context = LocalContext.current
@@ -80,6 +83,8 @@ fun LocationsScreenContent(
itemsIndexed(locations, key = { _, item -> item.id }) { index, location ->
val weather = weatherMap[location.id]
val alert = alertMap[location.id] ?: emptyList()
val icon = weather?.current?.weatherCondition
?: WeatherCondition.NO_CONDITION_FOUND
@@ -129,7 +134,8 @@ fun LocationsScreenContent(
onLongClick = { onLongClick(location) },
isDefault = location.isDefault,
isDeviceLocation = location.isDeviceLocation,
shape = shape
shape = shape,
isAlertAvailable = alert.isNotEmpty()
)
@@ -10,6 +10,7 @@ import com.pranshulgg.weather_master_app.core.model.domain.AppException
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.domain.toMessageRes
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.data.repository.AlertsRepository
import com.pranshulgg.weather_master_app.data.repository.LocationsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
@@ -20,7 +21,8 @@ import javax.inject.Inject
@HiltViewModel
class LocationsScreenViewModel @Inject constructor(
private val locationsRepo: LocationsRepository
private val locationsRepo: LocationsRepository,
private val alertsRepository: AlertsRepository
) : ViewModel() {
val allLocationsWeather = locationsRepo.getWeatherForAllLocations().stateIn(
@@ -29,6 +31,12 @@ class LocationsScreenViewModel @Inject constructor(
initialValue = emptyList()
)
val allLocationsAlerts = alertsRepository.getAllLocationsAlerts().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun updateDefaultLocation(id: String) {
viewModelScope.launch {
locationsRepo.updateDefaultLocation(id)
@@ -11,12 +11,20 @@ import com.pranshulgg.weather_master_app.core.ui.components.SettingsTileIcon
fun LocationScreenSheetContent(
locationName: String,
onDelete: () -> Unit,
onSetAsDefault: () -> Unit
onSetAsDefault: () -> Unit,
onEdit: () -> Unit
) {
SettingSection(
isModalOption = true,
title = locationName,
tiles = listOf(
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.edit_24px) },
title = stringResource(R.string.location_edit),
onClick = {
onEdit()
}
),
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.delete_24px) },
title = stringResource(R.string.action_delete),
@@ -13,7 +13,8 @@ import com.pranshulgg.weather_master_app.feature.locations.components.LocationSc
@Composable
fun LocationScreenSheet(
viewModel: LocationsScreenViewModel,
sheetState: SheetState
sheetState: SheetState,
onEdit: () -> Unit
) {
val uiState = viewModel.uiState.value
@@ -42,6 +43,10 @@ fun LocationScreenSheet(
if (uiState.longClickedLocation != null && !uiState.longClickedLocation.isDefault) {
viewModel.updateDefaultLocation(uiState.longClickedLocation.id)
}
},
onEdit = {
hide()
onEdit()
}
)
@@ -24,11 +24,13 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.airquality.AirQuality
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.domain.weather.Weather
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherUnits
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.ui.navigation.NavRoutes
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.feature.intro.IntroScreen
import com.pranshulgg.weather_master_app.feature.locations.LocationsScreen
@@ -50,7 +52,8 @@ data class MainScreenWeatherUiState(
val isInitialized: Boolean = false,
val airQuality: AirQuality? = null,
val isAirQualityLoading: Boolean = false,
val isUnsupportedSource: Boolean = false
val isUnsupportedSource: Boolean = false,
val alerts: List<Alert> = emptyList()
)
data class MainScreenUiState(
@@ -63,8 +66,7 @@ data class MainScreenUiState(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(navController: NavController) {
val weatherViewModel: WeatherViewModel = hiltViewModel()
fun MainScreen(navController: NavController, weatherViewModel: WeatherViewModel) {
val uiState by weatherViewModel.uiState
val viewModel: MainScreenViewModel = hiltViewModel()
val mainScreenUiState = viewModel.uiState.value
@@ -146,7 +148,8 @@ fun MainScreen(navController: NavController) {
weatherViewModel.setActiveLocation(it)
}
},
isTabletLike = isTabletLike
isTabletLike = isTabletLike,
weatherViewModel
)
},
drawerState = drawerState,
@@ -166,7 +169,7 @@ fun MainScreen(navController: NavController) {
}
},
onEditLocation = {
viewModel.showWeatherSourcesForLocationSheet(uiState.isLoading)
navController.navigate(NavRoutes.editLocation(activeLocation!!.id))
},
context,
onWeatherSourceInfoClick = viewModel::showWeatherSourcesInfoForLocationSheet,
@@ -176,21 +179,6 @@ fun MainScreen(navController: NavController) {
)
// WEATHER SOURCES DIALOG
SharedBottomSheet.WeatherSourcesForLocationSheet(
countryCode = activeLocation?.countryCode,
show = viewModel.uiState.value.isWeatherSourcesForLocationSheetOpen,
isEditing = true,
selectedSource = activeLocation?.source ?: WeatherSource.OPEN_METEO,
onSave = {
if (activeLocation != null) {
weatherViewModel.updateSourceForLocation(activeLocation, it)
}
},
onDismiss = viewModel::hideWeatherSourcesForLocationSheet,
sheetState = sheetState
)
// WEATHER SOURCES INFO DIALOG
MainScreenBottomSheets.WeatherSourcesInfoForLocationSheet(viewModel, activeLocation, sheetState)
@@ -17,6 +17,7 @@ import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.LoadingIndicator
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
@@ -31,9 +32,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.pranshulgg.weather_master_app.core.prefs.LocalAppPrefs
import com.pranshulgg.weather_master_app.core.ui.navigation.NavRoutes
import com.pranshulgg.weather_master_app.feature.main.components.CreditsBottomSection
import com.pranshulgg.weather_master_app.feature.main.components.FroggyContainer
import com.pranshulgg.weather_master_app.feature.main.components.MainSearchBar
import com.pranshulgg.weather_master_app.feature.main.ui.AlertsSection
import com.pranshulgg.weather_master_app.feature.main.ui.BackgroundGradient
import com.pranshulgg.weather_master_app.feature.main.ui.CurrentWeatherCard
import com.pranshulgg.weather_master_app.feature.main.ui.weatherAnimations.WeatherAnimations
@@ -60,6 +63,7 @@ fun MainScreenScaffold(
val weather = remember(uiState.weather) { uiState.weather }
val airQuality = remember(uiState.airQuality) { uiState.airQuality }
val prefs = LocalAppPrefs.current
val alerts = remember(uiState.alerts) { uiState.alerts }
val units = uiState.weatherUnits
val scrollState = rememberScrollState()
@@ -156,6 +160,20 @@ fun MainScreenScaffold(
),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (alerts.isNotEmpty()) {
AlertsSection(
alerts,
prefs,
weather.location.timezone,
onAlertClick = {
navController.navigate(
NavRoutes.alerts(
weather.location.id
)
)
}
)
}
if (isShowSummary) {
SummaryCard(
weather,
@@ -30,6 +30,7 @@ import com.pranshulgg.weather_master_app.core.ui.components.Tooltip
import com.pranshulgg.weather_master_app.core.ui.navigation.NavRoutes
import com.pranshulgg.weather_master_app.core.ui.theme.ShadowElevation
import com.pranshulgg.weather_master_app.core.ui.theme.isThemeDark
import com.pranshulgg.weather_master_app.core.utils.weather.location.getFullLocationName
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,20 +53,6 @@ fun MainSearchBar(
}
}
val locationText = activeLocation?.let {
buildString {
append(activeLocation.name)
if (activeLocation.country.isNotBlank()) {
append(", ")
}
if (activeLocation.state.isNotBlank()) {
append(activeLocation.state)
append(", ")
}
append(activeLocation.country)
}
} ?: "••••"
Surface(
color = if (isFroggyLayout) getSearchBarColor(isDark = isThemeDark()) else Color.Transparent,
@@ -107,7 +94,7 @@ fun MainSearchBar(
}
Gap(horizontal = 4.dp)
Text(
locationText,
getFullLocationName(activeLocation),
color = if (isFroggyLayout) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
@@ -0,0 +1,80 @@
package com.pranshulgg.weather_master_app.feature.main.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.alerts.Alert
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertSeverity
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
import com.pranshulgg.weather_master_app.core.prefs.AppPrefsState
import com.pranshulgg.weather_master_app.core.ui.components.SettingSection
import com.pranshulgg.weather_master_app.core.ui.components.Symbol
import com.pranshulgg.weather_master_app.core.ui.theme.ShadowElevation
import com.pranshulgg.weather_master_app.core.utils.formatters.safeZoneId
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun AlertsSection(
alerts: List<Alert>,
prefs: AppPrefsState,
zoneId: String,
onAlertClick: () -> Unit
) {
val pattern = if (prefs.is24HrTimeFormat) "MMM dd, HH:mm" else "MMM dd, hh:mm a"
val formatter: (Long) -> String = {
val formatter = DateTimeFormatter.ofPattern(pattern)
val instant = Instant.ofEpochMilli(it)
val dateTime = instant.atZone(safeZoneId(zoneId)).toLocalDateTime()
formatter.format(dateTime)
}
Surface(
color = MaterialTheme.colorScheme.surface,
shape = MaterialTheme.shapes.extraLarge,
shadowElevation = ShadowElevation.level2
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
alerts.forEach {
ListItem(
colors = ListItemDefaults.colors(
containerColor = Color.Transparent
),
modifier = Modifier.clickable(onClick = onAlertClick),
leadingContent = {
Symbol(
R.drawable.warning_24px,
color = it.severity?.color ?: AlertSeverity.UNKNOWN.color,
size = 32.dp
)
},
headlineContent = { Text(it.event) },
supportingContent = {
if (it.effective != null && it.expires != null) {
Text("${formatter(it.effective)} • ${formatter(it.expires)}")
}
}
)
}
}
}
}
@@ -20,6 +20,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pranshulgg.weather_master_app.R
import com.pranshulgg.weather_master_app.core.model.domain.location.Location
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.ui.components.ActionBottomSheet
import com.pranshulgg.weather_master_app.core.ui.components.DialogBasic
@@ -42,7 +44,6 @@ object MainScreenBottomSheets {
val uiState = viewModel.uiState.value
val uriHandler = LocalUriHandler.current
val airQualityUrl = "https://open-meteo.com/en/docs/air-quality-api"
if (uiState.isWeatherSourcesInfoForLocationSheetOpen) {
ActionBottomSheet(
@@ -65,20 +66,38 @@ object MainScreenBottomSheets {
)
)
)
Gap(8.dp)
SettingSection(
title = stringResource(R.string.weather_air_quality),
tiles = listOf(
SettingTile.ActionTile(
title = WeatherSource.OPEN_METEO.displayName,
description = airQualityUrl,
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) },
onClick = {
uriHandler.openUri(airQualityUrl)
}
if (location.airQualitySource != AirQualitySource.NONE) {
Gap(8.dp)
SettingSection(
title = stringResource(R.string.weather_air_quality),
tiles = listOf(
SettingTile.ActionTile(
title = location.airQualitySource.fullName,
description = location.airQualitySource.displayLink,
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) },
onClick = {
uriHandler.openUri(location.airQualitySource.displayLink)
}
)
)
)
)
}
if (location.alertSource != AlertSource.NONE) {
Gap(8.dp)
SettingSection(
title = "Alerts",
tiles = listOf(
SettingTile.ActionTile(
title = location.alertSource.fullName,
description = location.alertSource.displayLink,
trailing = { SettingsTileIcon(R.drawable.open_in_new_24px) },
onClick = {
uriHandler.openUri(location.alertSource.displayLink)
}
)
)
)
}
}
}
}
@@ -132,16 +132,15 @@ fun SearchScreen(navController: NavController) {
SettingSection(
tiles = results.map {
SettingTile.ActionTile(
title = it.name,
description = "${if (it.state.isNotEmpty()) "${it.state}, " else ""}${it.country}",
description = "${if (it.state.isNotBlank()) "${it.state}, " else ""}${it.country}",
trailing = {
if (selectedLocationId == it.id) {
LoadingIndicator()
}
},
onClick = {
if (selectedLocationId.isNotEmpty()) return@ActionTile
if (selectedLocationId.isNotBlank()) return@ActionTile
selectedLocation = it
viewModel.showWeatherSourcesForLocationSheet()
}
@@ -47,12 +47,12 @@ fun SettingsScreen(navController: NavController) {
description = stringResource(R.string.setting_appearance_secondary),
onClick = { navController.navigate(NavRoutes.APPEARANCE) }
),
SettingTile.ActionTile(
leading = { SettingsTileIcon(R.drawable.favorite_24px) },
title = "Support",
description = "If you enjoy the app, consider supporting its development on Github",
onClick = { uriHandler.openUri("https://github.com/sponsors/PranshulGG?frequency=one-time&sponsor=PranshulGG") }
)
// SettingTile.ActionTile(
// leading = { SettingsTileIcon(R.drawable.favorite_24px) },
// title = "Support",
// description = "If you enjoy the app, consider supporting its development",
// onClick = { uriHandler.openUri("https://github.com/sponsors/PranshulGG?frequency=one-time&sponsor=PranshulGG") }
// )
)
)
@@ -1,6 +1,7 @@
package com.pranshulgg.weather_master_app.feature.shared
import android.content.Context
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
@@ -11,13 +12,17 @@ import com.pranshulgg.weather_master_app.core.model.domain.toAppException
import com.pranshulgg.weather_master_app.core.model.domain.toMessageRes
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlock
import com.pranshulgg.weather_master_app.core.model.sources.AirQualitySource
import com.pranshulgg.weather_master_app.core.model.sources.AlertSource
import com.pranshulgg.weather_master_app.core.model.sources.WeatherSource
import com.pranshulgg.weather_master_app.core.model.sources.isGlobal
import com.pranshulgg.weather_master_app.core.model.sources.isSourceSupportedFor
import com.pranshulgg.weather_master_app.core.model.weather.WeatherResult
import com.pranshulgg.weather_master_app.core.model.weather.airquality.AirQualityResult
import com.pranshulgg.weather_master_app.core.model.weather.alerts.AlertResult
import com.pranshulgg.weather_master_app.core.network.sources.airquality.openmeteo.OpenMeteoAqiRepository
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
import com.pranshulgg.weather_master_app.data.provider.AirQualityRepositoryProvider
import com.pranshulgg.weather_master_app.data.provider.AlertsRepositoryProvider
import com.pranshulgg.weather_master_app.data.provider.WeatherRepositoryProvider
import com.pranshulgg.weather_master_app.data.repository.LocationsRepository
import com.pranshulgg.weather_master_app.data.repository.WeatherBlocksRepository
@@ -36,6 +41,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
@HiltViewModel
class WeatherViewModel @Inject constructor(
@@ -45,6 +51,8 @@ class WeatherViewModel @Inject constructor(
private val weatherBlocksRepository: WeatherBlocksRepository,
private val openMeteoAqiRepository: OpenMeteoAqiRepository,
private val weatherDataReconcilerRepository: WeatherDataReconcilerRepository,
private val airQualityRepositoryProvider: AirQualityRepositoryProvider,
private val alertRepositoryProvider: AlertsRepositoryProvider,
@ApplicationContext private val context: Context
) : ViewModel() {
@@ -108,7 +116,8 @@ class WeatherViewModel @Inject constructor(
source: WeatherSource,
isManualRefresh: Boolean = false,
isForceRefresh: Boolean = false,
isForceRefreshForAirQuality: Boolean = false
isForceRefreshForAirQuality: Boolean = false,
isForceRefreshForAlerts: Boolean = false
) {
setLoading(true)
weatherJob?.cancel()
@@ -125,7 +134,10 @@ class WeatherViewModel @Inject constructor(
// Run separately
if (!_uiState.value.isError) {
launch {
handleAirQuality(location, isManualRefresh)
handleAirQuality(location, isManualRefresh, isForceRefreshForAirQuality)
}
launch {
handleAlerts(location, isManualRefresh, isForceRefreshForAlerts)
}
}
@@ -139,8 +151,10 @@ class WeatherViewModel @Inject constructor(
delay(minLoadingTime - elapsed)
}
setLoading(false)
}
}
@@ -166,50 +180,51 @@ class WeatherViewModel @Inject constructor(
getWeather(location, location.source)
}
fun updateSourceForLocation(location: Location, source: WeatherSource) {
val updatedLocation = location.copy(source = source)
viewModelScope.launch {
locationsRepo.updateSourceForLocation(location.id, source)
val allowForceRefresh = location.source != source
if (allowForceRefresh) {
weatherDataReconcilerRepository.cleanUpStaleData(location.source, location.id)
}
_uiState.value = _uiState.value.copy(
activeLocation = updatedLocation
)
getWeather(
updatedLocation,
source,
isForceRefresh = allowForceRefresh
)
}
}
private fun handleSourceChangeForWeather(
fun handleSourceChangeForWeather(
location: Location,
source: WeatherSource,
airQualitySource: AirQualitySource,
alertSource: AlertSource
) {
val updatedLocation = location.copy(source = source, airQualitySource = airQualitySource)
val updatedLocation = location.copy(
source = source,
airQualitySource = airQualitySource,
alertSource = alertSource
)
viewModelScope.launch {
locationsRepo.updateSourceForLocation(location.id, source)
locationsRepo.updateAirQualitySourceForLocation(location.id, airQualitySource)
locationsRepo.updateAlertSourceForLocation(location.id, alertSource)
val allowForceRefreshForWeather = location.source != source
val allowForceRefreshForAirQuality = location.airQualitySource != airQualitySource
val allowForceRefreshForAlerts = location.alertSource != alertSource
if (allowForceRefreshForWeather) {
weatherDataReconcilerRepository.cleanUpStaleData(location.source, location.id)
weatherDataReconcilerRepository.cleanUpStaleData(
location.source,
location.id,
airQualitySource,
alertSource
)
}
if (allowForceRefreshForAirQuality) {
weatherDataReconcilerRepository.cleanUpStaleAirQualityData(
location.id,
source,
alertSource
)
}
if (allowForceRefreshForAlerts) {
weatherDataReconcilerRepository.cleanUpStaleAlertsData(
location.id,
source,
airQualitySource
)
}
_uiState.value = _uiState.value.copy(
activeLocation = updatedLocation
@@ -218,7 +233,8 @@ class WeatherViewModel @Inject constructor(
updatedLocation,
source,
isForceRefresh = allowForceRefreshForWeather,
isForceRefreshForAirQuality = allowForceRefreshForAirQuality
isForceRefreshForAirQuality = allowForceRefreshForAirQuality,
isForceRefreshForAlerts = allowForceRefreshForAlerts
)
@@ -305,25 +321,79 @@ class WeatherViewModel @Inject constructor(
}
}
private suspend fun handleAirQuality(location: Location, isManualRefresh: Boolean) {
private suspend fun handleAirQuality(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
) {
if (_uiState.value.isAirQualityLoading) return
val repoAir = airQualityRepositoryProvider.getRepository(location.airQualitySource)
if (repoAir == null) {
_uiState.value = _uiState.value.copy(airQuality = null, isAirQualityLoading = false)
return
}
_uiState.value = _uiState.value.copy(isAirQualityLoading = true)
when (val result = openMeteoAqiRepository.getAirQuality(location, isManualRefresh)) {
when (val result = repoAir.getAirQuality(location, isManualRefresh, isForceRefresh)) {
is AirQualityResult.Success -> {
_uiState.value = _uiState.value.copy(airQuality = result.airquality)
_uiState.value = _uiState.value.copy(isAirQualityLoading = false)
_uiState.value =
_uiState.value.copy(
airQuality = result.airquality,
isAirQualityLoading = false
)
}
// Fail silently, we just won't show the Air quality in the UI
is AirQualityResult.Error -> {
_uiState.value = _uiState.value.copy(airQuality = result.cacheAirQuality)
_uiState.value = _uiState.value.copy(isAirQualityLoading = false)
_uiState.value = _uiState.value.copy(
airQuality = result.cacheAirQuality,
isAirQualityLoading = false
)
}
}
}
private suspend fun handleAlerts(
location: Location,
isManualRefresh: Boolean,
isForceRefresh: Boolean
) {
val repoAlert = alertRepositoryProvider.getRepository(location.alertSource)
if (repoAlert == null) {
_uiState.value = _uiState.value.copy(alerts = emptyList())
return
}
when (val result = repoAlert.getAlerts(location, isManualRefresh, isForceRefresh)) {
is AlertResult.Success -> {
_uiState.value =
_uiState.value.copy(
alerts = result.alerts
)
}
// Fail silently, we just won't show the Air quality in the UI
is AlertResult.Error -> {
_uiState.value = _uiState.value.copy(
alerts = result.cacheAlerts
)
}
}
}
}
@@ -35,11 +35,12 @@ fun LocationItem(
isDefault: Boolean = false,
onLongClick: () -> Unit,
isDeviceLocation: Boolean = false,
shape: RoundedCornerShape
shape: RoundedCornerShape,
isAlertAvailable: Boolean = false
) {
val containerColor =
if (isSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceBright
if (isSelected) MaterialTheme.colorScheme.secondaryContainer else if (isAlertAvailable) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceBright
val contentColor =
if (isSelected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface
@@ -92,6 +93,9 @@ fun LocationItem(
if (isDeviceLocation) {
Symbol(R.drawable.circle_circle_24px, color = contentColor)
}
if (isAlertAvailable) {
Symbol(R.drawable.warning_24px, color = MaterialTheme.colorScheme.error)
}
}
)
}
@@ -41,6 +41,7 @@ import com.pranshulgg.weather_master_app.core.utils.formatters.to24HourTimeStrin
import com.pranshulgg.weather_master_app.core.utils.weather.cache.isWeatherHourlyDomainSafe
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findMatchingDaily
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.findMatchingHourly
import com.pranshulgg.weather_master_app.core.utils.weather.forecast.isSameDay
import com.pranshulgg.weather_master_app.feature.blocks.components.NoHourlyDataAvailable
import com.pranshulgg.weather_master_app.feature.shared.components.CardsHeader
import java.time.ZoneId
@@ -64,7 +65,8 @@ fun HourlyCard(
weather.hourly,
currentMilli,
weather.location.source,
weather.location.timezone
weather.location.timezone,
alwaysReturn24Hrs = true
)
@@ -123,7 +125,8 @@ fun HourlyCard(
precipitationProbability = item.precipitationProbability ?: 0,
temperature = temperature,
isNow = index == 0,
icon = item.weatherCondition.toIcon(matchingDaily, item.time)
icon = item.weatherCondition.toIcon(matchingDaily, item.time),
isSameDay = isSameDay(item.time, currentMilli, weather.location.timezone)
)
if (index == filteredHourly.size - 1) Gap(horizontal = 10.dp)
@@ -141,12 +144,14 @@ private fun HourlyItem(
precipitationProbability: Int,
temperature: Double?,
isNow: Boolean,
icon: Int
icon: Int,
isSameDay: Boolean = true
) {
Column(
modifier = Modifier
.height(120.dp)
.alpha(if (!isSameDay) 0.6f else 1f)
.padding(horizontal = 4.dp),
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.CenterHorizontally
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="@android:color/white"
android:pathData="M109,840Q98,840 89,834.5Q80,829 75,820Q70,811 69.5,800.5Q69,790 75,780L445,140Q451,130 460.5,125Q470,120 480,120Q490,120 499.5,125Q509,130 515,140L885,780Q891,790 890.5,800.5Q890,811 885,820Q880,829 871,834.5Q862,840 851,840L109,840ZM508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720Q497,720 508.5,708.5ZM508.5,588.5Q520,577 520,560L520,440Q520,423 508.5,411.5Q497,400 480,400Q463,400 451.5,411.5Q440,423 440,440L440,560Q440,577 451.5,588.5Q463,600 480,600Q497,600 508.5,588.5Z"/>
</vector>
+2 -2
View File
@@ -279,8 +279,8 @@
<string name="weather_wind_direction_from">Von %1$s</string>
<string name="day_length_hr">%1$s Std</string>
<string name="error_no_results">Keine Ergebnisse gefunden</string>
<string name="summary_overview_template_same_condition">Es wird den ganzen Tag und die ganze Nacht %1$s bleiben.</string>
<string name="summary_overview_template_day_night_condition">Am Tag wird es %1$s sein und in der Nacht %2$s werden.</string>
<string name="summary_overview_template_same_condition">It will stay %1$s throughout the day and night.</string>
<string name="summary_overview_template_day_night_condition">Am Tag wird es %1$s sein und in der Nacht %2$s werden.</string>
<string name="summary_rain_template_day_night">Tagsüber wird gegen %1$s Regen erwartet, mit einer Wahrscheinlichkeit von %2$s. Heute Nacht besteht gegen %4$s eine Regenwahrscheinlichkeit von %3$s.</string>
<string name="summary_rain_template_day">Tagsüber wird gegen %1$s Regen erwartet, mit einer Wahrscheinlichkeit von %2$s.</string>
<string name="summary_rain_template_night">Heute Nacht besteht gegen %2$s eine Regenwahrscheinlichkeit von %1$s.</string>
+5 -5
View File
@@ -284,11 +284,11 @@
<string name="summary_rain_template_day_night">Durante el día, se espera lluvia alrededor de las %1$s con una probabilidad del %2$s. Esta noche, hay una probabilidad del %3$s que llueva alrededor de las %4$s</string>
<string name="summary_rain_template_day">Durante el día, se espera lluvia alrededor de las %1$s con una probabilidad de %2$s.</string>
<string name="summary_rain_template_night">Esta noche, hay una probabilidad del %1$s que llueva alrededor de las %2$s.</string>
<string name="summary_snow_template_day_night"></string>
<string name="summary_snow_template_day">Se pronostica nieve alrededor del %1$s durante el día con una probabilidad del %2$s.</string>
<string name="summary_snow_template_night">Durante la noche, hay un %1$s de probabilidad de nieve alrededor del %2$s.</string>
<string name="summary_temp_template_extreme">Se espera calor extremo durante el día con una máxima de %1$s. Las temperaturas bajarán alrededor de %2$s.</string>
<string name="summary_temp_template_warm">Hará calor durante el día, con máximas cercanas a %1$s. Esta noche, las temperaturas bajarán alrededor de %2$s</string>
<string name="summary_snow_template_day_night">Snow is forecast around %1$s during the day with a %2$s chance. Overnight, snowfall remains possible with a %3$s chance around %4$s.</string>
<string name="summary_snow_template_day">Snow is forecast around %1$s during the day with a %2$s chance.</string>
<string name="summary_snow_template_night">Overnight, there\'s a %1$s chance of snow around %2$s.</string>
<string name="summary_temp_template_extreme">Expect extreme heat during the day with a high of %1$s. Temperatures will ease to around %2$s tonight.</string>
<string name="summary_temp_template_warm">It will be warm during the day with highs near %1$s. Tonight, temperatures will fall to around %2$s.</string>
<string name="summary_temp_template_mild">A mild day is expected with temperatures reaching %1$s. Overnight, it\'ll cool to around %2$s.</string>
<string name="summary_temp_template_cool">Hará frío durante el día, con máximas cercanas a %1$s. Esta noche, las temperaturas bajarán alrededor de %2$s</string>
<string name="summary_temp_template_cold">Cold weather continues with daytime temperatures around %1$s. Overnight, lows will fall to around %2$s.</string>
+1 -1
View File
@@ -281,7 +281,7 @@
<string name="day_length_hr">%1$s tuntia</string>
<string name="error_no_results">Tuloksia ei löytynyt</string>
<string name="summary_overview_template_same_condition">Luvassa %1$s päivällä ja yöllä.</string>
<string name="summary_overview_template_day_night_condition">Päivällä luvassa poutaa, yöksi %2$s.</string>
<string name="summary_overview_template_day_night_condition">Päivällä on %1$s, yöllä muuttuu %2$s.</string>
<string name="summary_rain_template_day_night">Päivällä on luvassa sadetta noin kello %1$s %2$s todennäköisyydellä. Yöllä on %3$s todennäköisyys sateeseen noin kello %4$s.</string>
<string name="summary_rain_template_day">Päivällä on luvassa sadetta noin kello %1$s %2$s todennäköisyydellä.</string>
<string name="summary_rain_template_night">Yöllä on %1$s todennäköisyys sateeseen noin kello %2$s.</string>
+1 -1
View File
@@ -280,7 +280,7 @@
<string name="weather_wind_direction_from">%1$s风</string>
<string name="day_length_hr">%1$s 小时</string>
<string name="error_no_results">未找到结果</string>
<string name="summary_overview_template_same_condition"></string>
<string name="summary_overview_template_same_condition">It will stay %1$s throughout the day and night</string>
<string name="summary_overview_template_day_night_condition">日间将以%1$s为主,夜间转为%2$s。</string>
<string name="summary_rain_template_day_night">During the day, rain is expected around %1$s with a %2$s chance. Tonight, there\'s a %3$s chance of rain around %4$s.</string>
<string name="summary_rain_template_day">During the day, rain is expected around %1$s with a %2$s chance.</string>
+14
View File
@@ -381,6 +381,20 @@
<string name="weather_aqi_source">Air quality source</string>
<string name="location_name">Location name</string>
<string name="location_info">Location information</string>
<string name="location_long_press_info">Long press a location for more options</string>
<string name="alert_severity_critical">Critical</string>
<string name="alert_severity_high">High</string>
<string name="alert_severity_medium">Medium</string>
<string name="alert_severity_low">Low</string>
<string name="alert_severity_unknown">Unknown</string>
<string name="confirm_delete_location">Delete Location</string>
<string name="alert_delete_location_secondary">Are you sure you want to delete this location?</string>
<string name="weather_alerts">Alerts</string>
</resources>
<!--<string name=""></string>-->