Add sync UI: settings dialog, per-notebook sync, sync all
- SyncSettingsDialog: server URL, username, password fields - Gear icon in library top bar opens sync settings - "Sync to server" in notebook overflow menu - "Sync all" button next to filter/sort bar - Both check isSyncConfigured() and prompt for settings if needed - Sync runs on Dispatchers.IO, shows Toast on success/error - Sync settings stored in SharedPreferences via EngPadApp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,9 +23,30 @@ class EngPadApp : Application() {
|
|||||||
|
|
||||||
fun setViewAllPages(value: Boolean) = prefs.edit { putBoolean(KEY_VIEW_ALL_PAGES, value) }
|
fun setViewAllPages(value: Boolean) = prefs.edit { putBoolean(KEY_VIEW_ALL_PAGES, value) }
|
||||||
|
|
||||||
|
fun getSyncServerUrl(): String = prefs.getString(KEY_SYNC_SERVER_URL, "") ?: ""
|
||||||
|
|
||||||
|
fun setSyncServerUrl(url: String) = prefs.edit { putString(KEY_SYNC_SERVER_URL, url) }
|
||||||
|
|
||||||
|
fun getSyncUsername(): String = prefs.getString(KEY_SYNC_USERNAME, "") ?: ""
|
||||||
|
|
||||||
|
fun setSyncUsername(username: String) = prefs.edit { putString(KEY_SYNC_USERNAME, username) }
|
||||||
|
|
||||||
|
fun getSyncPassword(): String = prefs.getString(KEY_SYNC_PASSWORD, "") ?: ""
|
||||||
|
|
||||||
|
fun setSyncPassword(password: String) = prefs.edit { putString(KEY_SYNC_PASSWORD, password) }
|
||||||
|
|
||||||
|
fun isSyncConfigured(): Boolean {
|
||||||
|
return getSyncServerUrl().isNotBlank() &&
|
||||||
|
getSyncUsername().isNotBlank() &&
|
||||||
|
getSyncPassword().isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS_NAME = "engpad_prefs"
|
private const val PREFS_NAME = "engpad_prefs"
|
||||||
private const val KEY_LAST_NOTEBOOK = "last_notebook_id"
|
private const val KEY_LAST_NOTEBOOK = "last_notebook_id"
|
||||||
private const val KEY_VIEW_ALL_PAGES = "view_all_pages"
|
private const val KEY_VIEW_ALL_PAGES = "view_all_pages"
|
||||||
|
private const val KEY_SYNC_SERVER_URL = "sync_server_url"
|
||||||
|
private const val KEY_SYNC_USERNAME = "sync_username"
|
||||||
|
private const val KEY_SYNC_PASSWORD = "sync_password"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ import net.metacircular.engpad.data.model.Notebook
|
|||||||
import net.metacircular.engpad.data.model.PageSize
|
import net.metacircular.engpad.data.model.PageSize
|
||||||
import net.metacircular.engpad.data.repository.NotebookRepository
|
import net.metacircular.engpad.data.repository.NotebookRepository
|
||||||
import net.metacircular.engpad.data.repository.PageRepository
|
import net.metacircular.engpad.data.repository.PageRepository
|
||||||
|
import net.metacircular.engpad.data.sync.SyncClient
|
||||||
|
import net.metacircular.engpad.data.sync.SyncManager
|
||||||
import net.metacircular.engpad.ui.export.BackupExporter
|
import net.metacircular.engpad.ui.export.BackupExporter
|
||||||
import net.metacircular.engpad.ui.export.PdfExporter
|
import net.metacircular.engpad.ui.export.PdfExporter
|
||||||
|
import net.metacircular.engpad.ui.settings.SyncSettingsDialog
|
||||||
|
import net.metacircular.engpad.EngPadApp
|
||||||
|
import android.widget.Toast
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -83,6 +88,9 @@ fun NotebookListScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var showSyncSettings by remember { mutableStateOf(false) }
|
||||||
|
val app = context.applicationContext as EngPadApp
|
||||||
|
|
||||||
var filterText by remember { mutableStateOf("") }
|
var filterText by remember { mutableStateOf("") }
|
||||||
var sortField by remember { mutableStateOf(SortField.LAST_EDITED) }
|
var sortField by remember { mutableStateOf(SortField.LAST_EDITED) }
|
||||||
var sortDir by remember { mutableStateOf(SortDir.DESC) }
|
var sortDir by remember { mutableStateOf(SortDir.DESC) }
|
||||||
@@ -102,7 +110,16 @@ fun NotebookListScreen(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(title = { Text("Engineering Pad :: Library") })
|
TopAppBar(
|
||||||
|
title = { Text("Engineering Pad :: Library") },
|
||||||
|
actions = {
|
||||||
|
androidx.compose.material3.IconButton(
|
||||||
|
onClick = { showSyncSettings = true },
|
||||||
|
) {
|
||||||
|
Text("\u2699", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||||
@@ -161,6 +178,18 @@ fun NotebookListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
TextButton(onClick = {
|
||||||
|
if (!app.isSyncConfigured()) {
|
||||||
|
showSyncSettings = true
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
syncAllNotebooks(context, app, database, notebooks)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Sync all")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayedNotebooks.isEmpty()) {
|
if (displayedNotebooks.isEmpty()) {
|
||||||
@@ -194,6 +223,15 @@ fun NotebookListScreen(
|
|||||||
PdfExporter.shareFile(context, file, "application/zip")
|
PdfExporter.shareFile(context, file, "application/zip")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSync = {
|
||||||
|
if (!app.isSyncConfigured()) {
|
||||||
|
showSyncSettings = true
|
||||||
|
return@NotebookItem
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
syncNotebook(context, app, database, notebook.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +270,13 @@ fun NotebookListScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSyncSettings) {
|
||||||
|
SyncSettingsDialog(
|
||||||
|
app = app,
|
||||||
|
onDismiss = { showSyncSettings = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -241,6 +286,7 @@ private fun NotebookItem(
|
|||||||
onRename: () -> Unit,
|
onRename: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onExportBackup: () -> Unit,
|
onExportBackup: () -> Unit,
|
||||||
|
onSync: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) }
|
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) }
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
@@ -290,6 +336,10 @@ private fun NotebookItem(
|
|||||||
text = { Text("Export backup") },
|
text = { Text("Export backup") },
|
||||||
onClick = { showMenu = false; onExportBackup() },
|
onClick = { showMenu = false; onExportBackup() },
|
||||||
)
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Sync to server") },
|
||||||
|
onClick = { showMenu = false; onSync() },
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text("Delete") },
|
text = { Text("Delete") },
|
||||||
onClick = { showMenu = false; onDelete() },
|
onClick = { showMenu = false; onDelete() },
|
||||||
@@ -414,3 +464,85 @@ private fun RenameNotebookDialog(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a "host:port" server URL string into host and port components.
|
||||||
|
* Defaults to port 443 if not specified.
|
||||||
|
*/
|
||||||
|
private fun parseServerUrl(url: String): Pair<String, Int> {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
val colonIndex = trimmed.lastIndexOf(':')
|
||||||
|
return if (colonIndex > 0) {
|
||||||
|
val host = trimmed.substring(0, colonIndex)
|
||||||
|
val port = trimmed.substring(colonIndex + 1).toIntOrNull() ?: 443
|
||||||
|
host to port
|
||||||
|
} else {
|
||||||
|
trimmed to 443
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncNotebook(
|
||||||
|
context: android.content.Context,
|
||||||
|
app: EngPadApp,
|
||||||
|
database: EngPadDatabase,
|
||||||
|
notebookId: Long,
|
||||||
|
) {
|
||||||
|
val (host, port) = parseServerUrl(app.getSyncServerUrl())
|
||||||
|
val client = SyncClient(host, port, app.getSyncUsername(), app.getSyncPassword())
|
||||||
|
try {
|
||||||
|
val syncManager = SyncManager(
|
||||||
|
client,
|
||||||
|
database.notebookDao(),
|
||||||
|
database.pageDao(),
|
||||||
|
database.strokeDao(),
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
syncManager.syncNotebook(notebookId)
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Sync complete", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Sync failed: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncAllNotebooks(
|
||||||
|
context: android.content.Context,
|
||||||
|
app: EngPadApp,
|
||||||
|
database: EngPadDatabase,
|
||||||
|
notebooks: List<Notebook>,
|
||||||
|
) {
|
||||||
|
val (host, port) = parseServerUrl(app.getSyncServerUrl())
|
||||||
|
val client = SyncClient(host, port, app.getSyncUsername(), app.getSyncPassword())
|
||||||
|
try {
|
||||||
|
val syncManager = SyncManager(
|
||||||
|
client,
|
||||||
|
database.notebookDao(),
|
||||||
|
database.pageDao(),
|
||||||
|
database.strokeDao(),
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
for (notebook in notebooks) {
|
||||||
|
syncManager.syncNotebook(notebook.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Synced ${notebooks.size} notebook(s)",
|
||||||
|
Toast.LENGTH_SHORT,
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Sync failed: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package net.metacircular.engpad.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import net.metacircular.engpad.EngPadApp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SyncSettingsDialog(
|
||||||
|
app: EngPadApp,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var serverUrl by remember { mutableStateOf(app.getSyncServerUrl()) }
|
||||||
|
var username by remember { mutableStateOf(app.getSyncUsername()) }
|
||||||
|
var password by remember { mutableStateOf(app.getSyncPassword()) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Sync Settings") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = { serverUrl = it },
|
||||||
|
label = { Text("Server URL (host:port)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("Username") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Password") },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
app.setSyncServerUrl(serverUrl.trim())
|
||||||
|
app.setSyncUsername(username.trim())
|
||||||
|
app.setSyncPassword(password)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user