Skip to content

Commit 3d5d245

Browse files
committed
v0.7.0: settings polish — scroll on expand + long-press named backup / restore
CollapsibleSection now scrolls itself into view 80 ms after expanding, so sections near the bottom of the page show their content instead of staying hidden below the fold. Backup / Restore action buttons grew a long-press path. Long-press Backup prompts for a name (letters / numbers / spaces / dash / underscore, capped at 32 chars) and writes eucplanet_settings-NAME.json next to the default backup. If the file already exists, an overwrite confirm dialog appears. Long-press Restore opens a picker listing the default backup plus every named snapshot found in the sync folder. SyncManager exposes backupSettingsAs / listSettingsBackups / restoreSettingsFrom + a sanitizeBackupName helper. New BackupOutcome sealed type and BackupEntry record. Phone code 72 -> 73, wear 100073 -> 100074, versionName 0.6.9 -> 0.7.0. Strings localised across all 13 non-default locales.
1 parent 633614b commit 3d5d245

20 files changed

Lines changed: 500 additions & 19 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ android {
2727
applicationId = "com.eried.eucplanet"
2828
minSdk = 29
2929
targetSdk = 35
30-
versionCode = 72
31-
versionName = "0.6.9"
30+
versionCode = 73
31+
versionName = "0.7.0"
3232

3333
val buildStamp = SimpleDateFormat("yyMMdd.HHmm")
3434
.apply { timeZone = TimeZone.getTimeZone("UTC") }

app/src/main/java/com/eried/eucplanet/data/sync/SyncManager.kt

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class SyncManager @Inject constructor(
5252
companion object {
5353
private const val TAG = "SyncManager"
5454
const val SETTINGS_BACKUP_NAME = "eucplanet_settings.json"
55+
const val SETTINGS_BACKUP_PREFIX = "eucplanet_settings-"
56+
const val SETTINGS_BACKUP_SUFFIX = ".json"
5557
const val TRIPS_SUBFOLDER = "trips"
5658
const val UPLOAD_WORK_NAME = "trip_upload"
5759
}
@@ -337,33 +339,53 @@ class SyncManager @Inject constructor(
337339
}
338340

339341
/** Serialise AppSettings + alarm rules to JSON and write to SETTINGS_BACKUP_NAME. */
340-
suspend fun backupSettings(): Boolean {
342+
suspend fun backupSettings(): Boolean =
343+
backupSettingsAs(name = null, overwrite = true) == BackupOutcome.Saved
344+
345+
/**
346+
* Write a named backup file in the sync folder. [name] = null is the
347+
* default `eucplanet_settings.json`; a non-null sanitised name produces
348+
* `eucplanet_settings-{name}.json`. When [overwrite] is false and the
349+
* target already exists, returns [BackupOutcome.AlreadyExists] without
350+
* touching the file so the caller can prompt the rider.
351+
*/
352+
suspend fun backupSettingsAs(name: String?, overwrite: Boolean): BackupOutcome {
341353
val current = settingsRepository.get()
342-
val folder = getSyncFolder(current) ?: return false
354+
val folder = getSyncFolder(current) ?: return BackupOutcome.Failed
355+
val fileName = buildBackupFileName(name)
356+
val existing = folder.findFile(fileName)
357+
if (existing != null && !overwrite) return BackupOutcome.AlreadyExists
343358
val payload = SettingsJson.toJson(SettingsJson.stripDeviceBindings(current)).apply {
344359
put("alarms", alarmsToJson(alarmDao.getAll()))
345360
}
346361
val json = payload.toString(2)
347362
return try {
348-
val existing = folder.findFile(SETTINGS_BACKUP_NAME)
349363
existing?.delete()
350-
val file = folder.createFile("application/json", SETTINGS_BACKUP_NAME) ?: return false
364+
val file = folder.createFile("application/json", fileName)
365+
?: return BackupOutcome.Failed
351366
context.contentResolver.openOutputStream(file.uri)?.use { out ->
352367
out.write(json.toByteArray(Charsets.UTF_8))
353-
} ?: return false
354-
settingsRepository.update(current.copy(lastSettingsBackupAt = System.currentTimeMillis()))
355-
true
368+
} ?: return BackupOutcome.Failed
369+
// Only the default backup updates the "last backup" timestamp so
370+
// named snapshots don't reset the cadence indicator on the dashboard.
371+
if (name == null) {
372+
settingsRepository.update(current.copy(lastSettingsBackupAt = System.currentTimeMillis()))
373+
}
374+
BackupOutcome.Saved
356375
} catch (e: Exception) {
357376
Log.e(TAG, "Settings backup failed", e)
358-
false
377+
BackupOutcome.Failed
359378
}
360379
}
361380

362381
/** Read settings.json from the folder and apply — keeps current syncFolder/device fields. */
363-
suspend fun restoreSettings(): Boolean {
382+
suspend fun restoreSettings(): Boolean = restoreSettingsFrom(SETTINGS_BACKUP_NAME)
383+
384+
/** Restore from the named backup file in the sync folder. */
385+
suspend fun restoreSettingsFrom(fileName: String): Boolean {
364386
val current = settingsRepository.get()
365387
val folder = getSyncFolder(current) ?: return false
366-
val file = folder.findFile(SETTINGS_BACKUP_NAME) ?: return false
388+
val file = folder.findFile(fileName) ?: return false
367389
return try {
368390
val bytes = context.contentResolver.openInputStream(file.uri)?.use { it.readBytes() }
369391
?: return false
@@ -385,6 +407,52 @@ class SyncManager @Inject constructor(
385407
}
386408
}
387409

410+
/**
411+
* List every settings backup in the sync folder. The default
412+
* `eucplanet_settings.json` is always returned first (with [BackupEntry.label]
413+
* = null), followed by named snapshots sorted by display label.
414+
*/
415+
suspend fun listSettingsBackups(): List<BackupEntry> {
416+
val current = settingsRepository.get()
417+
val folder = getSyncFolder(current) ?: return emptyList()
418+
val out = mutableListOf<BackupEntry>()
419+
val named = mutableListOf<BackupEntry>()
420+
folder.listFiles().forEach { doc ->
421+
val n = doc.name ?: return@forEach
422+
if (!n.endsWith(".json", ignoreCase = true)) return@forEach
423+
when {
424+
n.equals(SETTINGS_BACKUP_NAME, ignoreCase = true) -> {
425+
out += BackupEntry(fileName = n, label = null)
426+
}
427+
n.startsWith(SETTINGS_BACKUP_PREFIX, ignoreCase = true) &&
428+
n.length > SETTINGS_BACKUP_PREFIX.length + SETTINGS_BACKUP_SUFFIX.length -> {
429+
val label = n.substring(
430+
SETTINGS_BACKUP_PREFIX.length,
431+
n.length - SETTINGS_BACKUP_SUFFIX.length
432+
)
433+
if (label.isNotEmpty()) named += BackupEntry(fileName = n, label = label)
434+
}
435+
}
436+
}
437+
named.sortBy { it.label?.lowercase() }
438+
return out + named
439+
}
440+
441+
/** Path-safe sanitiser. Strips anything that isn't [A-Za-z0-9_- ], trims,
442+
* collapses whitespace, caps at 32 chars. Empty input returns null so the
443+
* caller can show a validation error. */
444+
fun sanitizeBackupName(raw: String): String? {
445+
val cleaned = raw.trim()
446+
.replace(Regex("[^A-Za-z0-9_\\- ]"), "")
447+
.replace(Regex("\\s+"), " ")
448+
.trim()
449+
.take(32)
450+
return cleaned.takeIf { it.isNotEmpty() }
451+
}
452+
453+
private fun buildBackupFileName(name: String?): String =
454+
if (name == null) SETTINGS_BACKUP_NAME else "$SETTINGS_BACKUP_PREFIX$name$SETTINGS_BACKUP_SUFFIX"
455+
388456
/** Enqueue the trip upload worker. */
389457
fun enqueueTripUpload(settings: AppSettings) {
390458
if (settings.syncFolderUri == null) return
@@ -501,3 +569,19 @@ class SyncManager @Inject constructor(
501569
return out
502570
}
503571
}
572+
573+
/**
574+
* Result of a settings-backup attempt. [AlreadyExists] is only ever returned
575+
* when the caller asked not to overwrite.
576+
*/
577+
sealed interface BackupOutcome {
578+
data object Saved : BackupOutcome
579+
data object AlreadyExists : BackupOutcome
580+
data object Failed : BackupOutcome
581+
}
582+
583+
/**
584+
* One row in the restore picker. [label] = null is the default backup
585+
* (`eucplanet_settings.json`); non-null is the rider-supplied snapshot name.
586+
*/
587+
data class BackupEntry(val fileName: String, val label: String?)

0 commit comments

Comments
 (0)