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:
2026-03-24 21:41:48 -07:00
parent 1b9a5d29e7
commit 86a2ba0f6b
3 changed files with 238 additions and 1 deletions

View File

@@ -23,9 +23,30 @@ class EngPadApp : Application() {
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 {
private const val PREFS_NAME = "engpad_prefs"
private const val KEY_LAST_NOTEBOOK = "last_notebook_id"
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"
}
}

View File

@@ -47,8 +47,13 @@ import net.metacircular.engpad.data.model.Notebook
import net.metacircular.engpad.data.model.PageSize
import net.metacircular.engpad.data.repository.NotebookRepository
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.PdfExporter
import net.metacircular.engpad.ui.settings.SyncSettingsDialog
import net.metacircular.engpad.EngPadApp
import android.widget.Toast
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -83,6 +88,9 @@ fun NotebookListScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showSyncSettings by remember { mutableStateOf(false) }
val app = context.applicationContext as EngPadApp
var filterText by remember { mutableStateOf("") }
var sortField by remember { mutableStateOf(SortField.LAST_EDITED) }
var sortDir by remember { mutableStateOf(SortDir.DESC) }
@@ -102,7 +110,16 @@ fun NotebookListScreen(
Scaffold(
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(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()) {
@@ -194,6 +223,15 @@ fun NotebookListScreen(
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
@@ -241,6 +286,7 @@ private fun NotebookItem(
onRename: () -> Unit,
onDelete: () -> Unit,
onExportBackup: () -> Unit,
onSync: () -> Unit,
) {
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) }
var showMenu by remember { mutableStateOf(false) }
@@ -290,6 +336,10 @@ private fun NotebookItem(
text = { Text("Export backup") },
onClick = { showMenu = false; onExportBackup() },
)
DropdownMenuItem(
text = { Text("Sync to server") },
onClick = { showMenu = false; onSync() },
)
DropdownMenuItem(
text = { Text("Delete") },
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()
}
}

View File

@@ -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")
}
},
)
}