Add configurable buttons, separate settings screens and backup activity

- Add ButtonConfigActivity for customizing main screen buttons with
  drag-and-drop reordering and individual size options (S/M/L)
- Move storage settings to separate StorageSettingsActivity
- Move signature setting to storage settings (relevant for WebDAV sync)
- Move data backup to separate BackupActivity with export/import
- Make "more" overflow button configurable in size
- Simplify SettingsActivity to 3 navigation buttons
- Add logbook rename/delete functionality
- Improve S/M/L button contrast with visible borders
This commit is contained in:
2026-01-11 21:31:49 +01:00
parent b6110c2cbb
commit cbd18cd891
28 changed files with 1979 additions and 708 deletions

View File

@@ -1,354 +1,61 @@
package it.danieleverducci.lunatracker
import android.net.Uri
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.RadioButton
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.switchmaterial.SwitchMaterial
import com.thegrizzlylabs.sardineandroid.impl.SardineException
import it.danieleverducci.lunatracker.entities.Logbook
import it.danieleverducci.lunatracker.entities.LunaEvent
import it.danieleverducci.lunatracker.repository.FileLogbookRepository
import it.danieleverducci.lunatracker.repository.LocalSettingsRepository
import it.danieleverducci.lunatracker.repository.LogbookListObtainedListener
import it.danieleverducci.lunatracker.repository.WebDAVLogbookRepository
import okio.IOException
import org.json.JSONArray
import org.json.JSONObject
open class SettingsActivity : AppCompatActivity() {
protected lateinit var settingsRepository: LocalSettingsRepository
protected lateinit var radioDataLocal: RadioButton
protected lateinit var radioDataWebDAV: RadioButton
protected lateinit var textViewWebDAVUrl: TextView
protected lateinit var textViewWebDAVUser: TextView
protected lateinit var textViewWebDAVPass: TextView
protected lateinit var progressIndicator: LinearProgressIndicator
protected lateinit var switchNoBreastfeeding: SwitchMaterial
protected lateinit var textViewSignature: EditText
class SettingsActivity : AppCompatActivity() {
// Activity Result Launchers for Export/Import
private val exportLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri -> uri?.let { exportLogbookToUri(it) } }
private lateinit var settingsRepository: LocalSettingsRepository
private lateinit var textViewStorageStatus: TextView
private val importLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri -> uri?.let { importLogbookFromUri(it) } }
private val storageSettingsLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { _ ->
updateStorageStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
radioDataLocal = findViewById(R.id.settings_data_local)
radioDataWebDAV = findViewById(R.id.settings_data_webdav)
textViewWebDAVUrl = findViewById(R.id.settings_data_webdav_url)
textViewWebDAVUser = findViewById(R.id.settings_data_webdav_user)
textViewWebDAVPass = findViewById(R.id.settings_data_webdav_pass)
progressIndicator = findViewById(R.id.progress_indicator)
switchNoBreastfeeding = findViewById(R.id.switch_no_breastfeeding)
textViewSignature = findViewById(R.id.settings_signature)
textViewStorageStatus = findViewById(R.id.settings_storage_status)
findViewById<View>(R.id.settings_save).setOnClickListener({
validateAndSave()
})
findViewById<View>(R.id.settings_cancel).setOnClickListener({
findViewById<View>(R.id.settings_storage).setOnClickListener {
storageSettingsLauncher.launch(Intent(this, StorageSettingsActivity::class.java))
}
findViewById<View>(R.id.settings_button_config).setOnClickListener {
startActivity(Intent(this, ButtonConfigActivity::class.java))
}
findViewById<View>(R.id.settings_backup).setOnClickListener {
startActivity(Intent(this, BackupActivity::class.java))
}
findViewById<View>(R.id.settings_close).setOnClickListener {
finish()
})
findViewById<View>(R.id.settings_export).setOnClickListener({
startExport()
})
findViewById<View>(R.id.settings_import).setOnClickListener({
startImport()
})
}
settingsRepository = LocalSettingsRepository(this)
loadSettings()
}
fun loadSettings() {
override fun onResume() {
super.onResume()
updateStorageStatus()
}
private fun loadSettings() {
updateStorageStatus()
}
private fun updateStorageStatus() {
val dataRepo = settingsRepository.loadDataRepository()
val webDavCredentials = settingsRepository.loadWebdavCredentials()
val noBreastfeeding = settingsRepository.loadNoBreastfeeding()
val signature = settingsRepository.loadSignature()
when (dataRepo) {
LocalSettingsRepository.DATA_REPO.LOCAL_FILE -> radioDataLocal.isChecked = true
LocalSettingsRepository.DATA_REPO.WEBDAV -> radioDataWebDAV.isChecked = true
}
textViewSignature.setText(signature)
switchNoBreastfeeding.isChecked = noBreastfeeding
if (webDavCredentials != null) {
textViewWebDAVUrl.text = webDavCredentials[0]
textViewWebDAVUser.text = webDavCredentials[1]
textViewWebDAVPass.text = webDavCredentials[2]
textViewStorageStatus.text = when (dataRepo) {
LocalSettingsRepository.DATA_REPO.WEBDAV -> getString(R.string.settings_storage_status_webdav)
else -> getString(R.string.settings_storage_status_local)
}
}
fun validateAndSave() {
if (radioDataLocal.isChecked) {
// No validation required, just save
saveSettings()
return
}
// Try to connect to WebDAV and check if the save file already exists
val webDAVLogbookRepo = WebDAVLogbookRepository(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
progressIndicator.visibility = View.VISIBLE
webDAVLogbookRepo.listLogbooks(this, object: LogbookListObtainedListener{
override fun onLogbookListObtained(logbooksNames: ArrayList<String>) {
if (logbooksNames.isEmpty()) {
// TODO: Ask the user if he wants to upload the local ones or to create a new one
copyLocalLogbooksToWebdav(webDAVLogbookRepo, object: OnCopyLocalLogbooksToWebdavFinishedListener {
override fun onCopyLocalLogbooksToWebdavFinished(errors: String?) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if (errors == null) {
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, errors, Toast.LENGTH_SHORT).show()
}
})
}
})
} else {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
saveSettings()
Toast.makeText(this@SettingsActivity, R.string.settings_webdav_creation_ok, Toast.LENGTH_SHORT).show()
})
}
}
override fun onIOError(error: IOException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_network_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
override fun onWebDAVError(error: SardineException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
if(error.toString().contains("401")) {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_denied), Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SettingsActivity, getString(R.string.settings_webdav_error_generic) + error.toString(), Toast.LENGTH_SHORT).show()
}
})
}
override fun onError(error: Exception) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_generic_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
})
/*webDAVLogbookRepo.createLogbook(this, LogbookRepository.DEFAULT_LOGBOOK_NAME, object: WebDAVLogbookRepository.LogbookCreatedListener{
override fun onJSONError(error: JSONException) {
runOnUiThread({
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(this@SettingsActivity, getString(R.string.settings_json_error) + error.toString(), Toast.LENGTH_SHORT).show()
})
}
})*/
}
fun saveSettings() {
settingsRepository.saveDataRepository(
if (radioDataWebDAV.isChecked) LocalSettingsRepository.DATA_REPO.WEBDAV
else LocalSettingsRepository.DATA_REPO.LOCAL_FILE
)
settingsRepository.saveNoBreastfeeding(switchNoBreastfeeding.isChecked)
settingsRepository.saveSignature(textViewSignature.text.toString())
settingsRepository.saveWebdavCredentials(
textViewWebDAVUrl.text.toString(),
textViewWebDAVUser.text.toString(),
textViewWebDAVPass.text.toString()
)
finish()
}
/**
* Copies the local logbooks to webdav.
* @return success
*/
private fun copyLocalLogbooksToWebdav(webDAVLogbookRepository: WebDAVLogbookRepository, listener: OnCopyLocalLogbooksToWebdavFinishedListener) {
Thread(Runnable {
val errors = StringBuilder()
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
for (logbook in logbooks) {
// Copy only if does not already exist
val error = webDAVLogbookRepository.uploadLogbookIfNotExists(this, logbook.name)
if (error != null) {
if (errors.isNotEmpty())
errors.append("\n")
errors.append(String.format(getString(R.string.settings_webdav_upload_error), logbook.name, error))
}
}
listener.onCopyLocalLogbooksToWebdavFinished(
if (errors.isEmpty()) null else errors.toString()
)
}).start()
}
private interface OnCopyLocalLogbooksToWebdavFinishedListener {
fun onCopyLocalLogbooksToWebdavFinished(errors: String?)
}
// Export/Import functionality
private fun startExport() {
val timestamp = java.text.SimpleDateFormat("yyyyMMdd_HHmmss", java.util.Locale.US)
.format(java.util.Date())
exportLauncher.launch("lunatracker_backup_$timestamp.json")
}
private fun startImport() {
importLauncher.launch(arrayOf("application/json"))
}
private fun exportLogbookToUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val fileLogbookRepo = FileLogbookRepository()
val logbooks = fileLogbookRepo.getAllLogbooks(this)
val json = JSONObject().apply {
put("version", 1)
put("app", "LunaTracker")
put("exported_at", System.currentTimeMillis())
put("logbooks", JSONArray().apply {
logbooks.forEach { logbook ->
put(JSONObject().apply {
put("name", logbook.name)
put("events", JSONArray().apply {
logbook.logs.forEach { event ->
put(event.toJson())
}
})
})
}
})
}
contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(json.toString(2).toByteArray(Charsets.UTF_8))
}
val eventCount = logbooks.sumOf { it.logs.size }
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_success, eventCount),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.export_error) + e.message,
Toast.LENGTH_SHORT
).show()
}
}
}.start()
}
private fun importLogbookFromUri(uri: Uri) {
progressIndicator.visibility = View.VISIBLE
Thread {
try {
val jsonString = contentResolver.openInputStream(uri)?.bufferedReader()?.readText()
?: throw Exception("Could not read file")
val json = JSONObject(jsonString)
val version = json.optInt("version", 1)
val fileLogbookRepo = FileLogbookRepository()
var totalEvents = 0
if (json.has("logbooks")) {
// New format with multiple logbooks
val logbooksArray = json.getJSONArray("logbooks")
for (i in 0 until logbooksArray.length()) {
val logbookJson = logbooksArray.getJSONObject(i)
val name = logbookJson.optString("name", "")
val eventsArray = logbookJson.getJSONArray("events")
val logbook = Logbook(name)
for (j in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(j)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
} else if (json.has("events")) {
// Old format with single logbook
val name = json.optString("logbook_name", "")
val eventsArray = json.getJSONArray("events")
val logbook = Logbook(name)
for (i in 0 until eventsArray.length()) {
try {
logbook.logs.add(LunaEvent(eventsArray.getJSONObject(i)))
totalEvents++
} catch (e: IllegalArgumentException) {
// Skip invalid events
}
}
fileLogbookRepo.saveLogbook(this, logbook)
}
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_success, totalEvents),
Toast.LENGTH_SHORT
).show()
}
} catch (e: Exception) {
runOnUiThread {
progressIndicator.visibility = View.INVISIBLE
Toast.makeText(
this,
getString(R.string.import_error) + ": " + e.message,
Toast.LENGTH_LONG
).show()
}
}
}.start()
}
}
}