From 86a2ba0f6b9da4dc5fabedf411bd1949011bc8e0 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 24 Mar 2026 21:41:48 -0700 Subject: [PATCH] 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) --- .../net/metacircular/engpad/EngPadApp.kt | 21 +++ .../engpad/ui/notebooks/NotebookListScreen.kt | 134 +++++++++++++++++- .../engpad/ui/settings/SyncSettingsDialog.kt | 84 +++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/net/metacircular/engpad/ui/settings/SyncSettingsDialog.kt diff --git a/app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt b/app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt index 3eb3dbf..545844e 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/EngPadApp.kt @@ -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" } } diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt index 8910032..902a882 100644 --- a/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/notebooks/NotebookListScreen.kt @@ -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 { + 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, +) { + 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() + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/ui/settings/SyncSettingsDialog.kt b/app/src/main/kotlin/net/metacircular/engpad/ui/settings/SyncSettingsDialog.kt new file mode 100644 index 0000000..f1a648e --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/ui/settings/SyncSettingsDialog.kt @@ -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") + } + }, + ) +}