mirror of
https://github.com/PranshulGG/WeatherMaster.git
synced 2026-10-22 02:52:37 -05:00
Merge pull request #1065 from reveler-hub/feature/backup-restore
Add Backup & Restore (export/import locations and settings)
This commit is contained in:
15 files changed
+687
-3
No files matched your search
@@ -35,6 +35,11 @@ sealed class AppException(message: String? = null) : Exception(message) {
|
||||
|
||||
class EmptyResponseBody : AppException()
|
||||
|
||||
class BackupSchemaVersionUnsupported : AppException()
|
||||
class BackupFileCorrupted : AppException()
|
||||
class BackupFileIOError : AppException()
|
||||
class BackupMissingDefaultLocation : AppException()
|
||||
|
||||
}
|
||||
|
||||
fun AppException.toMessageRes(): Int {
|
||||
@@ -53,6 +58,10 @@ fun AppException.toMessageRes(): Int {
|
||||
is AppException.ApiKeyRejectedError -> R.string.error_api_key_rejected
|
||||
is AppException.SecureConnection -> R.string.error_secure_connection_failed
|
||||
is AppException.EmptyResponseBody -> R.string.error_empty_response_body
|
||||
is AppException.BackupSchemaVersionUnsupported -> R.string.error_backup_schema_unsupported
|
||||
is AppException.BackupFileCorrupted -> R.string.error_backup_file_corrupted
|
||||
is AppException.BackupFileIOError -> R.string.error_backup_file_io
|
||||
is AppException.BackupMissingDefaultLocation -> R.string.error_backup_missing_default_location
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.pranshulgg.weather_master_app.feature.settings.about.terms.TermsCondi
|
||||
import com.pranshulgg.weather_master_app.feature.settings.appearance.AppearanceScreen
|
||||
import com.pranshulgg.weather_master_app.feature.settings.background.BackgroundUpdatesScreen
|
||||
import com.pranshulgg.weather_master_app.feature.settings.background.WorkerInfoScreen
|
||||
import com.pranshulgg.weather_master_app.feature.settings.backup.BackupScreen
|
||||
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
|
||||
@@ -115,6 +116,11 @@ fun AppNavHost(
|
||||
) {
|
||||
BackgroundUpdatesScreen(navController)
|
||||
}
|
||||
composable(
|
||||
NavRoutes.BACKUP_RESTORE
|
||||
) {
|
||||
BackupScreen(navController)
|
||||
}
|
||||
composable(
|
||||
route = "${NavRoutes.DAILY}/{index}/{locationId}",
|
||||
arguments = listOf(
|
||||
|
||||
@@ -20,6 +20,8 @@ object NavRoutes {
|
||||
|
||||
const val BACKGROUND_UPDATES = "background_updates"
|
||||
|
||||
const val BACKUP_RESTORE = "backup_restore"
|
||||
|
||||
const val ABOUT = "about"
|
||||
|
||||
const val TERMS_CONDITIONS = "terms_conditions"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.pranshulgg.weather_master_app.data.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import com.pranshulgg.weather_master_app.core.model.domain.AppException
|
||||
import com.pranshulgg.weather_master_app.core.model.domain.weather.WeatherBlockType
|
||||
import com.pranshulgg.weather_master_app.core.prefs.helper.PreferencesHelper
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.AppPrefsBackup
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.BACKUP_SCHEMA_VERSION
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.WeatherBlockBackup
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.toBackupDto
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.toEntity
|
||||
import com.pranshulgg.weather_master_app.data.local.WeatherMasterDatabase
|
||||
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationKeysDao
|
||||
import com.pranshulgg.weather_master_app.data.local.dao.location.LocationsDao
|
||||
import com.pranshulgg.weather_master_app.data.local.dao.weather.ApiKeysDao
|
||||
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherBlocksDao
|
||||
import com.pranshulgg.weather_master_app.data.local.dao.weather.WeatherUnitsDao
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.weather.blocks.WeatherBlockEntity
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
|
||||
class BackupRepository @Inject constructor(
|
||||
private val db: WeatherMasterDatabase,
|
||||
private val locationsDao: LocationsDao,
|
||||
private val locationKeysDao: LocationKeysDao,
|
||||
private val apiKeysDao: ApiKeysDao,
|
||||
private val weatherUnitsDao: WeatherUnitsDao,
|
||||
private val weatherBlocksDao: WeatherBlocksDao
|
||||
) {
|
||||
|
||||
suspend fun buildBackup(includeApiKeys: Boolean): BackupPayload {
|
||||
val locations = locationsDao.getLocationsOnce()
|
||||
val locationKeys = locationKeysDao.getAllCityKeys()
|
||||
val apiKeys = if (includeApiKeys) apiKeysDao.getAllApiKeys() else emptyList()
|
||||
val weatherUnits = weatherUnitsDao.getOnce()
|
||||
val weatherBlocks = weatherBlocksDao.getBlocks()
|
||||
|
||||
return BackupPayload(
|
||||
exportedAt = System.currentTimeMillis(),
|
||||
locations = locations.map { it.toBackupDto() },
|
||||
locationKeys = locationKeys.map { it.toBackupDto() },
|
||||
apiKeys = apiKeys.map { it.toBackupDto() },
|
||||
weatherUnits = weatherUnits?.toBackupDto(),
|
||||
weatherBlocks = weatherBlocks.map { it.toBackupDto() },
|
||||
prefs = readPrefsBackup()
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun serializeBackup(includeApiKeys: Boolean): String {
|
||||
val payload = buildBackup(includeApiKeys)
|
||||
return Json { prettyPrint = true }.encodeToString(BackupPayload.serializer(), payload)
|
||||
}
|
||||
|
||||
suspend fun restoreBackup(payload: BackupPayload) {
|
||||
if (payload.schemaVersion > BACKUP_SCHEMA_VERSION) {
|
||||
throw AppException.BackupSchemaVersionUnsupported()
|
||||
}
|
||||
|
||||
// A location list with no default would leave the app in a state it never expects
|
||||
// (default-location lookups assume exactly one exists) - reject the whole import
|
||||
// rather than silently producing that state.
|
||||
if (payload.locations.none { it.isDefault }) {
|
||||
throw AppException.BackupMissingDefaultLocation()
|
||||
}
|
||||
|
||||
db.withTransaction {
|
||||
locationKeysDao.deleteAll()
|
||||
locationsDao.deleteAllLocations()
|
||||
locationsDao.insertAllLocations(payload.locations.map { it.toEntity() })
|
||||
locationKeysDao.insertAll(payload.locationKeys.map { it.toEntity() })
|
||||
|
||||
// An empty list means the export toggle was off (or there was nothing to export),
|
||||
// not "the user wants their keys deleted" - so existing keys are left untouched
|
||||
// instead of being wiped, unlike everything else in a full-replace restore.
|
||||
if (payload.apiKeys.isNotEmpty()) {
|
||||
apiKeysDao.deleteAllApiKeys()
|
||||
apiKeysDao.insertAll(payload.apiKeys.map { it.toEntity() })
|
||||
}
|
||||
|
||||
payload.weatherUnits?.let { weatherUnitsDao.insert(it.toEntity()) }
|
||||
|
||||
weatherBlocksDao.clearMainBlocks()
|
||||
weatherBlocksDao.clearDailyBlocks()
|
||||
weatherBlocksDao.insertBlocks(
|
||||
payload.weatherBlocks.mapNotNull { it.toEntityOrNull() }
|
||||
)
|
||||
}
|
||||
|
||||
payload.prefs.applyToPreferences()
|
||||
}
|
||||
|
||||
private fun readPrefsBackup(): AppPrefsBackup = AppPrefsBackup(
|
||||
appTheme = PreferencesHelper.getString("app_theme"),
|
||||
customThemeColor = PreferencesHelper.getString("custom_theme_color"),
|
||||
isCustomTheme = PreferencesHelper.getBool("isCustomTheme"),
|
||||
isDynamicTheme = PreferencesHelper.getBool("isDynamicTheme"),
|
||||
themeVariantType = PreferencesHelper.getString("theme_variant_type"),
|
||||
searchSource = PreferencesHelper.getString("searchSource"),
|
||||
backgroundUpdatesEnabled = PreferencesHelper.getBool("backgroundUpdatesEnabled"),
|
||||
backgroundUpdatesInterval = PreferencesHelper.getInt("backgroundUpdatesInterval"),
|
||||
isFroggyLayout = PreferencesHelper.getBool("isFroggyLayout"),
|
||||
isShowWeatherAnimations = PreferencesHelper.getBool("isShowWeatherAnimations"),
|
||||
isWeatherBasedTheme = PreferencesHelper.getBool("isWeatherBasedTheme"),
|
||||
is24HrTimeFormat = PreferencesHelper.getBool("is24HrTimeFormat"),
|
||||
isShowSummary = PreferencesHelper.getBool("isShowSummary"),
|
||||
isGoogleSansFlex = PreferencesHelper.getBool("isGoogleSansFlex")
|
||||
)
|
||||
|
||||
private fun AppPrefsBackup.applyToPreferences() {
|
||||
appTheme?.let { PreferencesHelper.setString("app_theme", it) }
|
||||
customThemeColor?.let { PreferencesHelper.setString("custom_theme_color", it) }
|
||||
isCustomTheme?.let { PreferencesHelper.setBool("isCustomTheme", it) }
|
||||
isDynamicTheme?.let { PreferencesHelper.setBool("isDynamicTheme", it) }
|
||||
themeVariantType?.let { PreferencesHelper.setString("theme_variant_type", it) }
|
||||
searchSource?.let { PreferencesHelper.setString("searchSource", it) }
|
||||
backgroundUpdatesEnabled?.let { PreferencesHelper.setBool("backgroundUpdatesEnabled", it) }
|
||||
backgroundUpdatesInterval?.let { PreferencesHelper.setInt("backgroundUpdatesInterval", it) }
|
||||
isFroggyLayout?.let { PreferencesHelper.setBool("isFroggyLayout", it) }
|
||||
isShowWeatherAnimations?.let { PreferencesHelper.setBool("isShowWeatherAnimations", it) }
|
||||
isWeatherBasedTheme?.let { PreferencesHelper.setBool("isWeatherBasedTheme", it) }
|
||||
is24HrTimeFormat?.let { PreferencesHelper.setBool("is24HrTimeFormat", it) }
|
||||
isShowSummary?.let { PreferencesHelper.setBool("isShowSummary", it) }
|
||||
isGoogleSansFlex?.let { PreferencesHelper.setBool("isGoogleSansFlex", it) }
|
||||
}
|
||||
|
||||
private fun WeatherBlockBackup.toEntityOrNull(): WeatherBlockEntity? {
|
||||
val blockType = runCatching { WeatherBlockType.valueOf(type) }.getOrNull() ?: return null
|
||||
return WeatherBlockEntity(
|
||||
isDaily = isDaily,
|
||||
type = blockType,
|
||||
isHidden = isHidden,
|
||||
position = position
|
||||
)
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
package com.pranshulgg.weather_master_app.data.backup.model
|
||||
|
||||
import com.pranshulgg.weather_master_app.core.model.sources.Source
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.DistanceUnit
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.PrecipitationUnit
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.PressureUnit
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.TemperatureUnit
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.WindSpeedUnit
|
||||
import com.pranshulgg.weather_master_app.core.model.weather.openmeteo.OpenMeteoModel
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.location.LocationKeyEntity
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.location.WeatherLocationEntity
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.weather.ApiKeyEntity
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.weather.blocks.WeatherBlockEntity
|
||||
import com.pranshulgg.weather_master_app.data.local.entity.weather.units.AppWeatherUnitsEntity
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
const val BACKUP_SCHEMA_VERSION = 1
|
||||
|
||||
// Dedicated DTOs rather than the Room @Entity classes directly - the entities' shape is
|
||||
// governed by WeatherMasterDatabase's migration history, which shouldn't also have to stay
|
||||
// backward-compatible with old export files.
|
||||
@Serializable
|
||||
data class BackupPayload(
|
||||
val schemaVersion: Int = BACKUP_SCHEMA_VERSION,
|
||||
val exportedAt: Long,
|
||||
val locations: List<LocationBackup>,
|
||||
val locationKeys: List<LocationKeyBackup>,
|
||||
val apiKeys: List<ApiKeyBackup> = emptyList(), // empty = opt-out was chosen, or none existed
|
||||
val weatherUnits: WeatherUnitsBackup?,
|
||||
val weatherBlocks: List<WeatherBlockBackup>,
|
||||
val prefs: AppPrefsBackup
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocationBackup(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val country: String,
|
||||
val lat: Double,
|
||||
val lon: Double,
|
||||
val timezone: String,
|
||||
val source: Source,
|
||||
val state: String? = null,
|
||||
val isFavorite: Boolean = false,
|
||||
val isPinned: Boolean = false,
|
||||
val countryCode: String? = null,
|
||||
val isDefault: Boolean = false,
|
||||
val isDeviceLocation: Boolean = false,
|
||||
val alertSource: Source,
|
||||
val airQualitySource: Source,
|
||||
val customName: String? = null,
|
||||
val openMeteoModel: OpenMeteoModel = OpenMeteoModel.BEST_MATCH,
|
||||
val alertsLastFetchedAt: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LocationKeyBackup(
|
||||
val locationId: String,
|
||||
val cityKey: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiKeyBackup(
|
||||
val source: Source,
|
||||
val apiKey: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WeatherUnitsBackup(
|
||||
val tempUnit: TemperatureUnit,
|
||||
val windUnit: WindSpeedUnit,
|
||||
val distanceUnit: DistanceUnit,
|
||||
val pressureUnit: PressureUnit,
|
||||
val precipitationUnit: PrecipitationUnit
|
||||
)
|
||||
|
||||
// type is stored as a plain string (WeatherBlockType.name), not the enum directly -
|
||||
// kotlinx.serialization fails the whole decode on an unrecognized enum constant, and a string
|
||||
// lets a future block type addition degrade to "skip this one row" instead.
|
||||
@Serializable
|
||||
data class WeatherBlockBackup(
|
||||
val isDaily: Boolean,
|
||||
val type: String,
|
||||
val isHidden: Boolean,
|
||||
val position: Int
|
||||
)
|
||||
|
||||
// Every field nullable: null means "leave this pref alone" on import, not "clear it" - so an
|
||||
// older/partial export file still imports cleanly.
|
||||
@Serializable
|
||||
data class AppPrefsBackup(
|
||||
val appTheme: String? = null,
|
||||
val customThemeColor: String? = null,
|
||||
val isCustomTheme: Boolean? = null,
|
||||
val isDynamicTheme: Boolean? = null,
|
||||
val themeVariantType: String? = null,
|
||||
val searchSource: String? = null,
|
||||
val backgroundUpdatesEnabled: Boolean? = null,
|
||||
val backgroundUpdatesInterval: Int? = null,
|
||||
val isFroggyLayout: Boolean? = null,
|
||||
val isShowWeatherAnimations: Boolean? = null,
|
||||
val isWeatherBasedTheme: Boolean? = null,
|
||||
val is24HrTimeFormat: Boolean? = null,
|
||||
val isShowSummary: Boolean? = null,
|
||||
val isGoogleSansFlex: Boolean? = null
|
||||
)
|
||||
|
||||
fun WeatherLocationEntity.toBackupDto(): LocationBackup = LocationBackup(
|
||||
id = id,
|
||||
name = name,
|
||||
country = country,
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
timezone = timezone,
|
||||
source = source,
|
||||
state = state,
|
||||
isFavorite = isFavorite,
|
||||
isPinned = isPinned,
|
||||
countryCode = countryCode,
|
||||
isDefault = isDefault,
|
||||
isDeviceLocation = isDeviceLocation,
|
||||
alertSource = alertSource,
|
||||
airQualitySource = airQualitySource,
|
||||
customName = customName,
|
||||
openMeteoModel = openMeteoModel,
|
||||
alertsLastFetchedAt = alertsLastFetchedAt
|
||||
)
|
||||
|
||||
fun LocationBackup.toEntity(): WeatherLocationEntity = WeatherLocationEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
country = country,
|
||||
lat = lat,
|
||||
lon = lon,
|
||||
timezone = timezone,
|
||||
source = source,
|
||||
state = state,
|
||||
isFavorite = isFavorite,
|
||||
isPinned = isPinned,
|
||||
countryCode = countryCode,
|
||||
isDefault = isDefault,
|
||||
isDeviceLocation = isDeviceLocation,
|
||||
alertSource = alertSource,
|
||||
airQualitySource = airQualitySource,
|
||||
customName = customName,
|
||||
openMeteoModel = openMeteoModel,
|
||||
alertsLastFetchedAt = alertsLastFetchedAt
|
||||
)
|
||||
|
||||
fun LocationKeyEntity.toBackupDto(): LocationKeyBackup = LocationKeyBackup(
|
||||
locationId = locationId,
|
||||
cityKey = cityKey
|
||||
)
|
||||
|
||||
fun LocationKeyBackup.toEntity(): LocationKeyEntity = LocationKeyEntity(
|
||||
locationId = locationId,
|
||||
cityKey = cityKey
|
||||
)
|
||||
|
||||
fun ApiKeyEntity.toBackupDto(): ApiKeyBackup = ApiKeyBackup(
|
||||
source = source,
|
||||
apiKey = apiKey
|
||||
)
|
||||
|
||||
fun ApiKeyBackup.toEntity(): ApiKeyEntity = ApiKeyEntity(
|
||||
source = source,
|
||||
apiKey = apiKey
|
||||
)
|
||||
|
||||
fun AppWeatherUnitsEntity.toBackupDto(): WeatherUnitsBackup = WeatherUnitsBackup(
|
||||
tempUnit = tempUnit,
|
||||
windUnit = windUnit,
|
||||
distanceUnit = distanceUnit,
|
||||
pressureUnit = pressureUnit,
|
||||
precipitationUnit = precipitationUnit
|
||||
)
|
||||
|
||||
fun WeatherUnitsBackup.toEntity(): AppWeatherUnitsEntity = AppWeatherUnitsEntity(
|
||||
tempUnit = tempUnit,
|
||||
windUnit = windUnit,
|
||||
distanceUnit = distanceUnit,
|
||||
pressureUnit = pressureUnit,
|
||||
precipitationUnit = precipitationUnit
|
||||
)
|
||||
|
||||
fun WeatherBlockEntity.toBackupDto(): WeatherBlockBackup = WeatherBlockBackup(
|
||||
isDaily = isDaily,
|
||||
type = type.name,
|
||||
isHidden = isHidden,
|
||||
position = position
|
||||
)
|
||||
+9
@@ -13,9 +13,18 @@ interface LocationKeysDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertCityKey(entity: LocationKeyEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<LocationKeyEntity>)
|
||||
|
||||
@Query("SELECT * FROM location_keys WHERE locationId = :locationId")
|
||||
suspend fun getCityKeyForLocation(locationId: String): LocationKeyEntity?
|
||||
|
||||
@Query("SELECT * FROM location_keys")
|
||||
suspend fun getAllCityKeys(): List<LocationKeyEntity>
|
||||
|
||||
@Query("DELETE FROM location_keys WHERE locationId = :locationId")
|
||||
suspend fun deleteCityKeyForLocation(locationId: String)
|
||||
|
||||
@Query("DELETE FROM location_keys")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
+6
@@ -19,6 +19,12 @@ interface LocationsDao {
|
||||
weatherLocation: WeatherLocationEntity
|
||||
)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.REPLACE)
|
||||
suspend fun insertAllLocations(locations: List<WeatherLocationEntity>)
|
||||
|
||||
@Query("DELETE FROM weather_locations")
|
||||
suspend fun deleteAllLocations()
|
||||
|
||||
@Query("SELECT * FROM weather_locations ORDER BY isDefault DESC")
|
||||
fun getLocations(): Flow<List<WeatherLocationEntity>>
|
||||
|
||||
|
||||
+6
@@ -12,6 +12,9 @@ interface ApiKeysDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertApiKeyForSource(apiKeyEntity: ApiKeyEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<ApiKeyEntity>)
|
||||
|
||||
@Query("SELECT * FROM api_keys WHERE source = :source")
|
||||
suspend fun getApiKeyForSource(source: Source): ApiKeyEntity?
|
||||
|
||||
@@ -20,4 +23,7 @@ interface ApiKeysDao {
|
||||
|
||||
@Query("UPDATE api_keys SET apiKey = :apiKey WHERE source = :source")
|
||||
suspend fun updateApiKeyForSource(source: Source, apiKey: String)
|
||||
|
||||
@Query("DELETE FROM api_keys")
|
||||
suspend fun deleteAllApiKeys()
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.pranshulgg.weather_master_app.feature.intro
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -65,6 +68,13 @@ fun IntroScreen(navController: NavController) {
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var backgroundLocationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
|
||||
var locationPermissionInfoDialogOpen by remember { mutableStateOf(false) }
|
||||
val isImportingBackup = viewModel.isImportingBackup
|
||||
|
||||
val importBackupLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let { viewModel.importBackup(it) }
|
||||
}
|
||||
|
||||
val continueWithLocation = {
|
||||
isLoading = true
|
||||
@@ -146,7 +156,7 @@ fun IntroScreen(navController: NavController) {
|
||||
)
|
||||
Gap(28.dp)
|
||||
Button(
|
||||
enabled = !isLoading,
|
||||
enabled = !isLoading && !isImportingBackup,
|
||||
onClick = {
|
||||
locationPermissionInfoDialogOpen = true
|
||||
},
|
||||
@@ -163,7 +173,7 @@ fun IntroScreen(navController: NavController) {
|
||||
}
|
||||
Gap(12.dp)
|
||||
OutlinedButton(
|
||||
enabled = !isLoading,
|
||||
enabled = !isLoading && !isImportingBackup,
|
||||
onClick = {
|
||||
navController.navigate(NavRoutes.SEARCH)
|
||||
},
|
||||
@@ -179,6 +189,22 @@ fun IntroScreen(navController: NavController) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Gap(4.dp)
|
||||
TextButton(
|
||||
enabled = !isLoading && !isImportingBackup,
|
||||
onClick = {
|
||||
importBackupLauncher.launch(arrayOf("application/json"))
|
||||
},
|
||||
modifier = Modifier.heightIn(btnSize),
|
||||
contentPadding = ButtonDefaults.contentPaddingFor(btnSize),
|
||||
shapes = ButtonDefaults.shapes()
|
||||
) {
|
||||
Text(
|
||||
if (isImportingBackup) "Importing..." else "Import a backup",
|
||||
style = ButtonDefaults.textStyleFor(btnSize),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
||||
+48
-1
@@ -1,25 +1,43 @@
|
||||
package com.pranshulgg.weather_master_app.feature.intro
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.toAppException
|
||||
import com.pranshulgg.weather_master_app.core.model.domain.toMessageRes
|
||||
import com.pranshulgg.weather_master_app.core.network.sources.address.nominatim.json.NominatimRepository
|
||||
import com.pranshulgg.weather_master_app.core.prefs.AppPrefs
|
||||
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
|
||||
import com.pranshulgg.weather_master_app.data.backup.BackupRepository
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
|
||||
import com.pranshulgg.weather_master_app.data.provider.devicelocation.DeviceLocation
|
||||
import com.pranshulgg.weather_master_app.data.repository.LocationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class IntroScreenViewModel @Inject constructor(
|
||||
val locationsRepo: LocationsRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val nominatimRepository: NominatimRepository
|
||||
private val nominatimRepository: NominatimRepository,
|
||||
private val backupRepository: BackupRepository
|
||||
) : ViewModel() {
|
||||
|
||||
var isImportingBackup by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun saveDeviceLocation(location: DeviceLocation) {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -45,4 +63,33 @@ class IntroScreenViewModel @Inject constructor(
|
||||
|
||||
}
|
||||
|
||||
fun importBackup(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
isImportingBackup = true
|
||||
try {
|
||||
val text = context.contentResolver.openInputStream(uri)?.use {
|
||||
it.readBytes().decodeToString()
|
||||
} ?: throw AppException.BackupFileIOError()
|
||||
|
||||
val payload = Json { ignoreUnknownKeys = true }
|
||||
.decodeFromString(BackupPayload.serializer(), text)
|
||||
|
||||
backupRepository.restoreBackup(payload)
|
||||
AppPrefs.initPrefs(context)
|
||||
// MainScreen watches the location list reactively and steps past this screen
|
||||
// itself once it's non-empty - no explicit navigation needed here.
|
||||
} catch (e: SerializationException) {
|
||||
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
|
||||
} catch (e: IOException) {
|
||||
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
|
||||
} catch (e: Exception) {
|
||||
SnackbarManager.show(e.toAppException().toMessageRes())
|
||||
} finally {
|
||||
isImportingBackup = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -98,6 +98,12 @@ fun SettingsScreen(navController: NavController) {
|
||||
description = getCurrentAppLocale().displayName,
|
||||
onClick = { navController.navigate(NavRoutes.LANGUAGE) }
|
||||
),
|
||||
SettingTile.ActionTile(
|
||||
leading = { SettingsTileIcon(R.drawable.cloud_download_24px) },
|
||||
title = stringResource(R.string.setting_backup_restore),
|
||||
description = stringResource(R.string.setting_backup_restore_secondary),
|
||||
onClick = { navController.navigate(NavRoutes.BACKUP_RESTORE) }
|
||||
),
|
||||
)
|
||||
)
|
||||
SettingSection(
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.pranshulgg.weather_master_app.feature.settings.backup
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.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.SettingsTileIcon
|
||||
import com.pranshulgg.weather_master_app.core.ui.components.TextAlertDialog
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun BackupScreen(navController: NavController) {
|
||||
val viewModel: BackupScreenViewModel = hiltViewModel()
|
||||
|
||||
var isImportConfirmDialogOpen by remember { mutableStateOf(false) }
|
||||
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val exportLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let { viewModel.exportTo(it) }
|
||||
}
|
||||
|
||||
val importLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
pendingImportUri = uri
|
||||
isImportConfirmDialogOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
LargeTopBarScaffold(
|
||||
title = stringResource(R.string.setting_backup_restore),
|
||||
navigationIcon = { NavigateUpBtn(navController) },
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
SettingSection(
|
||||
title = stringResource(R.string.backup_export_action),
|
||||
tiles = listOf(
|
||||
SettingTile.SwitchTile(
|
||||
leading = { SettingsTileIcon(R.drawable.key_24px) },
|
||||
title = stringResource(R.string.backup_include_api_keys),
|
||||
description = stringResource(R.string.backup_include_api_keys_secondary),
|
||||
checked = viewModel.includeApiKeys,
|
||||
enabled = !viewModel.loading,
|
||||
onCheckedChange = { viewModel.onIncludeApiKeysChanged(it) }
|
||||
),
|
||||
SettingTile.ActionTile(
|
||||
leading = { SettingsTileIcon(R.drawable.cloud_upload_24px) },
|
||||
title = stringResource(R.string.backup_export_action),
|
||||
description = stringResource(R.string.backup_export_action_secondary),
|
||||
onClick = {
|
||||
exportLauncher.launch(defaultBackupFileName())
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
SettingSection(
|
||||
title = stringResource(R.string.backup_import_action),
|
||||
tiles = listOf(
|
||||
SettingTile.ActionTile(
|
||||
leading = { SettingsTileIcon(R.drawable.cloud_download_24px) },
|
||||
title = stringResource(R.string.backup_import_action),
|
||||
description = stringResource(R.string.backup_import_action_secondary),
|
||||
danger = true,
|
||||
onClick = {
|
||||
importLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextAlertDialog(
|
||||
show = isImportConfirmDialogOpen,
|
||||
title = stringResource(R.string.backup_import_confirm_title),
|
||||
message = stringResource(R.string.backup_import_confirm_body),
|
||||
confirmText = stringResource(R.string.action_confirm),
|
||||
dismissText = stringResource(R.string.action_cancel),
|
||||
onConfirm = {
|
||||
pendingImportUri?.let { viewModel.importFrom(it) }
|
||||
},
|
||||
onDismiss = {
|
||||
isImportConfirmDialogOpen = false
|
||||
pendingImportUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun defaultBackupFileName(): String {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
return "weathermaster_backup_$date.json"
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.pranshulgg.weather_master_app.feature.settings.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.pranshulgg.weather_master_app.R
|
||||
import com.pranshulgg.weather_master_app.core.model.domain.AppException
|
||||
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.prefs.AppPrefs
|
||||
import com.pranshulgg.weather_master_app.core.ui.snackbar.SnackbarManager
|
||||
import com.pranshulgg.weather_master_app.data.backup.BackupRepository
|
||||
import com.pranshulgg.weather_master_app.data.backup.model.BackupPayload
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BackupScreenViewModel @Inject constructor(
|
||||
private val backupRepository: BackupRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
var loading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var includeApiKeys by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun onIncludeApiKeysChanged(value: Boolean) {
|
||||
includeApiKeys = value
|
||||
}
|
||||
|
||||
fun exportTo(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
loading = true
|
||||
try {
|
||||
val json = backupRepository.serializeBackup(includeApiKeys)
|
||||
context.contentResolver.openOutputStream(uri)?.use {
|
||||
it.write(json.toByteArray(Charsets.UTF_8))
|
||||
} ?: throw AppException.BackupFileIOError()
|
||||
|
||||
SnackbarManager.show(R.string.message_backup_exported)
|
||||
} catch (e: IOException) {
|
||||
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
|
||||
} catch (e: Exception) {
|
||||
SnackbarManager.show(e.toAppException().toMessageRes())
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importFrom(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
loading = true
|
||||
try {
|
||||
val text = context.contentResolver.openInputStream(uri)?.use {
|
||||
it.readBytes().decodeToString()
|
||||
} ?: throw AppException.BackupFileIOError()
|
||||
|
||||
val payload = Json { ignoreUnknownKeys = true }
|
||||
.decodeFromString(BackupPayload.serializer(), text)
|
||||
|
||||
backupRepository.restoreBackup(payload)
|
||||
AppPrefs.initPrefs(context)
|
||||
|
||||
SnackbarManager.show(R.string.message_backup_restored)
|
||||
} catch (e: SerializationException) {
|
||||
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SnackbarManager.show(AppException.BackupFileCorrupted().toMessageRes())
|
||||
} catch (e: IOException) {
|
||||
SnackbarManager.show(AppException.BackupFileIOError().toMessageRes())
|
||||
} catch (e: Exception) {
|
||||
SnackbarManager.show(e.toAppException().toMessageRes())
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="M260,800q-91 0-155.5-63T40,583q0-78 47-139t123-78q25-92 100-149t170-57q117 0 198.5 81.5T760,440q69 8 114.5 59.5T920,620q0 75-52.5 127.5T740,800H520q-33 0-56.5-23.5T440,720v-206l-64 62-56-56 160-160 160 160-56 56-64-62v206h220q42 0 71-29t29-71q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480,240q-83 0-141.5 58.5T280,440h-20q-58 0-99 41t-41 99q0 58 41 99t99 41h100v80H260Zm220-280Z"/>
|
||||
</vector>
|
||||
@@ -245,6 +245,23 @@
|
||||
<string name="about_check_for_updates">Check for updates</string>
|
||||
<string name="action_view">View</string>
|
||||
|
||||
<string name="setting_backup_restore">Backup & Restore</string>
|
||||
<string name="setting_backup_restore_secondary">Export or import your locations and settings</string>
|
||||
<string name="backup_export_action">Export backup</string>
|
||||
<string name="backup_export_action_secondary">Save your locations and settings to a file</string>
|
||||
<string name="backup_import_action">Import backup</string>
|
||||
<string name="backup_import_action_secondary">Replace your current locations and settings from a file</string>
|
||||
<string name="backup_include_api_keys">Include API keys</string>
|
||||
<string name="backup_include_api_keys_secondary">Store your saved API keys in the backup file</string>
|
||||
<string name="backup_import_confirm_title">Import backup?</string>
|
||||
<string name="backup_import_confirm_body">This will replace your current locations, units, blocks, and settings with the contents of the backup file. This can\'t be undone.</string>
|
||||
<string name="message_backup_exported">Backup exported</string>
|
||||
<string name="message_backup_restored">Backup restored</string>
|
||||
<string name="error_backup_schema_unsupported">This backup file was made with a newer version of the app and can\'t be imported</string>
|
||||
<string name="error_backup_file_corrupted">This backup file is invalid or corrupted</string>
|
||||
<string name="error_backup_file_io">Couldn\'t read or write the backup file</string>
|
||||
<string name="error_backup_missing_default_location">This backup doesn\'t have a default location and can\'t be imported</string>
|
||||
|
||||
<string name="setting_weather_sources_info">Separate sources for each region are recommended based on the country code</string>
|
||||
|
||||
<string name="setting_weather_animations">Weather animations</string>
|
||||
|
||||
Reference in new issue
Block a user